Rive no App Router do Next.js: Client Components, SSR e WASM
Por equipe Charmotion · Atualizado em

No App Router do Next.js, renderize o Rive a partir de um Client Component marcado com "use client" e importe-o na página, que é um Server Component. Client Components também são pré-renderizados no servidor, então acesse window apenas dentro de effects. Mantenha o .riv em public/, passe dados do servidor, como o mood, em uma prop numérica e hospede o rive.wasm você mesmo, na versão exata do runtime.
Principais pontos
- Pages e layouts são Server Components; o componente do Rive precisa ser um Client Component.
- Client Components são pré-renderizados em HTML, então globais do navegador devem ficar em effects ou em useSyncExternalStore.
- next/dynamic com ssr: false só funciona dentro de um Client Component.
- Os dados do servidor atravessam a fronteira como props serializáveis, por exemplo um número de mood.
- A versão do WASM a hospedar é a do @rive-app/webgl2 ou @rive-app/canvas dentro de node_modules, não a do pacote React.
Já tem um personagem ou logo? Veja seu personagem em telas reais de app: telas de splash, onboarding, carregamento e estado vazio. É grátis, roda no navegador e não envia nada.
Onde o Rive roda no App Router?
No navegador. Pages e layouts do App Router são Server Components por padrão, e Server Components não podem usar state, effects nem APIs do navegador. O Rive precisa dos três: useRive é um hook, e o runtime desenha em um canvas com WebAssembly. Por isso, o componente que chama useRive precisa ser um Client Component, marcado com "use client" no topo do arquivo.
Mantenha essa fronteira pequena. O Next.js coloca no bundle do cliente tudo o que um arquivo "use client" importa, então marque o componente do mascote, e não a página ou o layout em volta dele. A página continua sendo um Server Component e apenas renderiza <Mascot />.
Um detalhe costuma surpreender: Client Components não rodam só no navegador. O Next.js os pré-renderiza em HTML no servidor e depois faz a hidratação no navegador. Quando renderizamos @rive-app/react-webgl2 4.37.1 no servidor com React 19, o resultado foi um canvas vazio, de tamanho zero, sem erros; a animação começa após a hidratação. Reserve o tamanho da caixa no CSS para que nada se desloque quando ela aparecer.
A configuração geral do React (pacotes, useRive, inputs vs hooks de view model) está em Rive no React e no Next.js. Esta página trata do que é específico do App Router.
Por que recebo "window is not defined" com o Rive?
Porque algum código que depende do navegador rodou durante a pré-renderização no servidor. Como os pacotes React atuais do Rive são importados e pré-renderizados sem problemas, a causa geralmente está no seu próprio componente:
- Ler
window,matchMediaoulocalStoragedurante o render, inclusive em um inicializador deuseState. - Criar
new Rive()a partir de@rive-app/webgl2ou@rive-app/canvasno escopo do módulo ou durante o render, em vez de dentro deuseEffect. - Chamar
RuntimeLoader.awaitInstance()no escopo do módulo, o que começa a carregar o WASM no servidor. - Uma biblioteca wrapper mais antiga que acessa
windowao ser importada.
Mova o trabalho do navegador para useEffect ou para event handlers, que nunca rodam no servidor. Para valores necessários durante o render, como a preferência de movimento reduzido, useSyncExternalStore aceita um snapshot separado para o servidor, o que também evita um hydration mismatch:
"use client";
import { useSyncExternalStore } from "react";
const query = "(prefers-reduced-motion: reduce)";
function subscribe(onChange: () => void) {
const mq = window.matchMedia(query);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
export function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches, // browser
() => false, // server prerender: no window
);
}
Quando a preferência for reduce, renderize um pôster estático no lugar do canvas; isso também evita o download do runtime. Se o erro vier de uma dependência que você não pode alterar, carregue o componente com ssr: false, como mostrado abaixo.
Quando devo usar next/dynamic com ssr: false?
Use quando uma dependência acessa globais do navegador assim que é importada, ou quando a animação fica abaixo da dobra e você quer tirar o JavaScript dela do bundle inicial. O Next.js só permite ssr: false em Client Components; em um Server Component, isso gera um erro. Então coloque-o em um arquivo "use client" próprio:
// app/components/mascot-lazy.tsx
"use client";
import dynamic from "next/dynamic";
export const MascotLazy = dynamic(() => import("./mascot"), {
ssr: false,
loading: () => (
<img src="/mascot-poster.webp" alt="" width={240} height={240} />
),
});
A contrapartida é que o servidor não envia HTML para essa parte da página. O placeholder loading preenche o espaço, então dê a ele a mesma largura e altura do canvas e use a pose de repouso do personagem, para que a troca seja imperceptível.
Para um mascote simples, geralmente isso não é necessário: um componente "use client" que mantém o trabalho do navegador dentro de effects é pré-renderizado sem problemas.
Como controlo o humor a partir de dados do servidor?
Busque os dados no Server Component e passe o resultado como prop. O Next.js exige que as props passadas a Client Components sejam serializáveis, e um número é, então o mood atravessa a fronteira sem problemas:
// app/dashboard/page.tsx (a Server Component: no "use client")
import Mascot from "@/app/components/mascot";
import { getImportStatus } from "@/lib/imports";
export default async function Page() {
const status = await getImportStatus(); // runs on the server
const mood = status === "running" ? 5 : status === "failed" ? 4 : 1;
return <Mascot mood={mood} celebrateKey={0} />; // a plain number crosses the boundary
}
O componente cliente define o número do view model sempre que a prop muda e dispara uma reação única quando um contador aumenta. Ele também hospeda o WASM, assunto da próxima seção:
// app/components/mascot.tsx
"use client";
import { useEffect } from "react";
import { preload } from "react-dom";
import {
RuntimeLoader,
useRive,
useViewModelInstanceNumber,
useViewModelInstanceTrigger,
} from "@rive-app/react-webgl2";
// Must match node_modules/@rive-app/webgl2/package.json (2.45.1 here).
const WASM_URL = "/rive/rive-2.45.1.wasm";
RuntimeLoader.setWasmUrl(WASM_URL);
type Props = { mood: number; celebrateKey: number };
export default function Mascot({ mood, celebrateKey }: Props) {
preload(WASM_URL, { as: "fetch", crossOrigin: "anonymous" });
preload("/mascot.riv", { as: "fetch", crossOrigin: "anonymous" });
const { rive, RiveComponent } = useRive({
src: "/mascot.riv",
stateMachine: "CharacterViewModelMachine",
autoplay: true,
autoBind: true,
});
const vmi = rive?.viewModelInstance;
const { setValue: setMood } = useViewModelInstanceNumber("mood", vmi);
const { trigger: success } = useViewModelInstanceTrigger("success", vmi);
useEffect(() => { setMood(mood); }, [vmi, mood, setMood]);
useEffect(() => { if (celebrateKey > 0) success(); }, [celebrateKey, success]);
return (
<div style={{ width: 240, height: 240 }}>
<RiveComponent aria-label="Animated mascot" />
</div>
);
}
O Rive avisa que seus hooks de propriedade rodam depois que o arquivo carrega, então o primeiro frame é renderizado com os valores salvos no arquivo. Se o frame inicial já precisar mostrar o mood vindo do servidor, defina-o no callback onRiveReady do useRive, que roda antes de a state machine avançar.
Triggers são eventos, não estado, então não vêm diretamente do servidor. Depois que uma Server Action for concluída, incremente um contador no estado do cliente, como neste formulário:
// app/settings/profile-form.tsx
"use client";
import { useState } from "react";
import Mascot from "@/app/components/mascot";
import { saveProfile } from "./actions"; // a Server Action
export function ProfileForm() {
const [mood, setMood] = useState(0);
const [cheer, setCheer] = useState(0);
async function submit(formData: FormData) {
setMood(5); // loading
const ok = await saveProfile(formData);
setMood(ok ? 1 : 4); // happy or sad
if (ok) setCheer((n) => n + 1); // plays the success reaction
}
return (
<form action={submit}>
<Mascot mood={mood} celebrateKey={cheer} />
{/* fields and a submit button */}
</form>
);
}
Os números de mood aqui seguem um arquivo do Charmotion, cujo view model Character tem mood de 0 a 9 (0 idle, 1 happy, 4 sad, 5 loading e assim por diante) e os triggers success, error, yes, no, enter e exit. Com o seu próprio arquivo, renomeie as propriedades para corresponder.
Como hospedar o rive.wasm e fazer o preload?
Por padrão, o runtime baixa o rive.wasm do unpkg quando a primeira instância do Rive é iniciada. A documentação do Rive lista motivos para hospedar o arquivo você mesmo (controle sobre a origem, início mais rápido, sua própria política de cache) e cinco regras para isso:
- A versão do WASM precisa corresponder exatamente à do pacote do runtime; uma divergência pode quebrar a renderização.
- A URL do
preloade a URL passada paraRuntimeLoader.setWasmUrl()precisam ser idênticas, ou o arquivo será baixado duas vezes. - Sirva o arquivo como
application/wasm. - Se ele estiver em outra origem, envie
Access-Control-Allow-Origin. - Faça cache do arquivo versionado como imutável e confirme que sua CDN comprime .wasm.
A armadilha da regra 1 é qual versão usar. O WASM vem em @rive-app/webgl2 (ou @rive-app/canvas), e o pacote React fixa essa dependência em uma versão própria: @rive-app/react-webgl2 4.37.1 instala @rive-app/webgl2 2.45.1. Leia a versão em node_modules/@rive-app/webgl2/package.json e copie o arquivo com esse número no nome:
mkdir -p public/rive
cp node_modules/@rive-app/webgl2/rive.wasm public/rive/rive-2.45.1.wasm
Refaça isso no mesmo commit de cada atualização do Rive. No componente do mascote acima, setWasmUrl aponta o runtime para esse arquivo, e o preload() do React com as: "fetch" adiciona o <link rel="preload"> correspondente ao head. O React exige crossOrigin em preloads do tipo fetch, e o Next.js documenta esses resource hints para Client Components.
O runtime também tem uma URL de fallback no jsDelivr, que ele tenta se o WASM principal não carregar. Se uma Content Security Policy restritiva permitir apenas a sua origem, chame RuntimeLoader.setWasmFallbackUrl(null) ou hospede o fallback também.
Tenha expectativas realistas sobre o LCP: um canvas não é candidato a LCP (o web.dev lista imagens, vídeo, imagens de fundo e blocos de texto). O preload faz o mascote aparecer mais cedo, mas o seu elemento de LCP geralmente é o título ou uma imagem de pôster. Dispense os preloads em páginas onde o mascote fica abaixo da dobra.
O Rive funciona com output: 'export'?
Sim. Um export estático pré-renderiza Client Components em HTML no build e copia public/ para out/, então o .riv e o WASM hospedado vão junto com o site, e o Rive inicia no navegador normalmente. A mesma regra vale: acesse window só depois que o componente for montado.
Duas coisas mudam. Primeiro, a opção headers em next.config.js não é suportada em um export estático, então configure o content type application/wasm e o header de cache no seu host. Segundo, se você publicar sob um basePath, como em um site de projeto no GitHub Pages, o Next.js o aplica aos links e espera que você mesmo o adicione às fontes do next/image. Strings simples, como o src do Rive e a URL do WASM, também precisam do prefixo, por exemplo /my-repo/mascot.riv.
Adicione um mascote Rive a um projeto Next.js com App Router
- Instale o runtime. Rode
npm install @rive-app/react-webgl2e anote a versão de@rive-app/webgl2que ele instala. - Adicione os arquivos. Coloque o .riv em
public/e copienode_modules/@rive-app/webgl2/rive.wasmparapublic/rive/com a versão no nome. - Crie um Client Component. Comece o arquivo com
"use client", chameRuntimeLoader.setWasmUrl()e renderize oRiveComponentdouseRivedentro de um wrapper de tamanho fixo. - Vincule o view model. Use
autoBind: truecomuseViewModelInstanceNumbereuseViewModelInstanceTriggerpara definir o humor e disparar reações. - Passe dados do servidor. Busque os dados na página Server Component e passe o humor para o Client Component como uma prop numérica.
- Proteja as APIs do navegador. Leia
windowematchMediaapenas em effects ou emuseSyncExternalStore, e usenext/dynamiccomssr: falsesomente se alguma dependência exigir.
Perguntas frequentes
Preciso de "use client" em todo arquivo que usa Rive?
Não. A diretiva marca uma fronteira: tudo o que o arquivo marcado importa e renderiza diretamente vira código de cliente. Coloque-a no topo do componente do mascote e importe esse componente a partir dos Server Components. Os componentes filhos que ele importa não precisam da própria diretiva.
Qual rive.wasm corresponde ao @rive-app/react-webgl2?
O que está dentro do pacote de runtime do qual ele depende. O @rive-app/react-webgl2 fixa o @rive-app/webgl2 em uma versão exata; por exemplo, a 4.37.1 fixa a 2.45.1. Copie node_modules/@rive-app/webgl2/rive.wasm e use essa versão no nome do arquivo. Para @rive-app/react-canvas, use @rive-app/canvas.
Por que meu canvas do Rive fica invisível após a hidratação no Next.js?
Geralmente o wrapper não tem altura. O RiveComponent se dimensiona de acordo com o elemento pai, e o canvas renderizado no servidor começa com tamanho zero; então um wrapper só com largura, ou um filho flex que colapsa, não mostra nada. Dê ao wrapper largura e altura explícitas ou uma proporção (aspect ratio).
Respostas rápidas deste guia
Fontes
- Server and Client Components, Next.js
- Lazy loading, Next.js
- Static exports, Next.js
- basePath, Next.js
- React runtime, Rive
- React runtime: Data binding, Rive
- Preloading WASM, Rive
- rive-wasm: web runtime source, Rive on GitHub
- preload, React
- useSyncExternalStore, React
- Largest Contentful Paint (LCP), web.dev