Ir al contenido
charmotion

Rive en el App Router de Next.js: Client Components, SSR y WASM

Por el equipo de Charmotion · Actualizado el

Ilustración de portada de la guía: Rive en el App Router de Next.js: Client Components, SSR y WASM, con personajes de demostración de Charmotion

En el App Router de Next.js, renderiza Rive desde un Client Component marcado con "use client" e impórtalo en tu página, que es un Server Component. Los Client Components se prerenderizan igualmente en el servidor, así que accede a window solo dentro de efectos. Guarda el .riv en public/, pasa los datos del servidor (como el estado de ánimo) como una prop numérica y aloja tú mismo rive.wasm con la versión exacta del runtime.

Puntos clave

  • Las páginas y los layouts son Server Components; el componente de Rive debe ser un Client Component.
  • Los Client Components se prerenderizan a HTML, así que las variables globales del navegador van en efectos o en useSyncExternalStore.
  • next/dynamic con ssr: false solo funciona dentro de un Client Component.
  • Los datos del servidor cruzan la frontera como props serializables, por ejemplo un número de mood.
  • La versión de WASM que debes alojar es la de @rive-app/webgl2 o @rive-app/canvas dentro de node_modules, no la del paquete de React.

¿Ya tienes un personaje o logo? Mira tu personaje en pantallas reales de una app: pantallas de splash, onboarding, carga y estado vacío. Es gratis, funciona en tu navegador y no sube nada.

¿Dónde se ejecuta Rive en el App Router?

En el navegador. Las páginas y los layouts del App Router son Server Components por defecto, y los Server Components no pueden usar estado, efectos ni APIs del navegador. Rive necesita las tres cosas: useRive es un hook, y el runtime dibuja en un canvas con WebAssembly. Por eso, el componente que llama a useRive debe ser un Client Component, marcado con "use client" al inicio de su archivo.

Mantén esa frontera lo más pequeña posible. Next.js incluye en el bundle del cliente todo lo que importa un archivo "use client", así que marca el componente de la mascota, no la página ni el layout que lo rodean. La página sigue siendo un Server Component y simplemente renderiza <Mascot />.

Hay un detalle que sorprende: los Client Components no son exclusivos del navegador. Next.js los prerenderiza a HTML en el servidor y luego los hidrata en el navegador. Cuando renderizamos en el servidor @rive-app/react-webgl2 4.37.1 con React 19, produjo un canvas vacío y de tamaño cero, sin errores; la animación empieza después de la hidratación. Reserva el tamaño de la caja en CSS para que nada se desplace cuando aparezca.

La configuración general de React (paquetes, useRive, inputs frente a hooks de view model) está en Rive en React y Next.js. Esta página cubre lo específico del App Router.

¿Por qué me aparece "window is not defined" con Rive?

Porque algún código que necesita el navegador se ejecutó durante el prerenderizado en el servidor. Como los paquetes actuales de Rive para React se importan y prerenderizan sin problemas, la causa suele estar en tu propio componente:

  • Leer window, matchMedia o localStorage durante el render, incluso en el inicializador de un useState.
  • Crear new Rive() desde @rive-app/webgl2 o @rive-app/canvas en el ámbito del módulo o durante el render, en lugar de hacerlo en useEffect.
  • Llamar a RuntimeLoader.awaitInstance() en el ámbito del módulo, lo que empieza a cargar el WASM en el servidor.
  • Una librería envoltorio (wrapper) antigua que accede a window al importarse.

Mueve el trabajo del navegador a useEffect o a manejadores de eventos, que nunca se ejecutan en el servidor. Para valores que necesitas durante el render, como la preferencia de movimiento reducido, useSyncExternalStore acepta una instantánea separada para el servidor, lo que además evita un error de hidratación (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
  );
}

Cuando la preferencia es reduce, renderiza un póster estático en lugar del canvas; así también te ahorras la descarga del runtime. Si el error viene de una dependencia que no puedes cambiar, carga el componente con ssr: false, como se explica abajo.

¿Cuándo debo usar next/dynamic con ssr: false?

Úsalo cuando una dependencia accede a variables globales del navegador en cuanto se importa, o cuando la animación está debajo del pliegue y quieres sacar su JavaScript del bundle inicial. Next.js solo permite ssr: false en Client Components; en un Server Component genera un error. Por eso, envuélvelo en su propio archivo "use client":

// 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} />
  ),
});

La contrapartida es que el servidor no envía HTML para esa parte de la página. El placeholder de loading cubre el hueco, así que dale el mismo ancho y alto que el canvas y usa la pose de reposo del personaje, para que el cambio sea invisible.

Para una mascota sencilla, normalmente no lo necesitas: un componente "use client" que mantiene el trabajo del navegador dentro de efectos se prerenderiza sin problemas.

¿Cómo controlo el estado de ánimo con datos del servidor?

Obtén los datos en el Server Component y pasa el resultado como prop. Next.js exige que las props que se pasan a Client Components sean serializables, y un número lo es, así que el estado de ánimo cruza la frontera sin 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
}

El componente de cliente actualiza el número del view model cada vez que cambia la prop y dispara una reacción puntual cuando un contador aumenta. También aloja el WASM por su cuenta, algo que se explica en la siguiente sección:

// 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>
  );
}

Rive aclara que sus hooks de propiedades se ejecutan después de que se carga el archivo, así que el primer fotograma se renderiza con los valores guardados en el archivo. Si el fotograma inicial ya debe mostrar el estado de ánimo del servidor, asígnalo en el callback onRiveReady de useRive, que se ejecuta antes de que avance la máquina de estados.

Los triggers son eventos, no estado, así que no vienen directamente del servidor. Cuando se resuelva una Server Action, incrementa un contador en el estado del cliente, como en este formulario:

// 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>
  );
}

Los números de mood de este ejemplo siguen un archivo de Charmotion, cuyo view model Character tiene mood de 0 a 9 (0 reposo, 1 feliz, 4 triste, 5 cargando, etc.) y los triggers success, error, yes, no, enter y exit. Con tu propio archivo, renombra las propiedades para que coincidan.

loading: mood 5, se repite en loop mientras el trabajo está en curso.

¿Cómo alojo rive.wasm por mi cuenta y lo precargo?

Por defecto, el runtime descarga rive.wasm desde unpkg cuando se inicia la primera instancia de Rive. La documentación de Rive enumera motivos para alojarlo tú mismo (control sobre el origen, arranque más rápido, tu propia política de caché) y cinco reglas si lo haces:

  1. La versión del WASM debe coincidir exactamente con la del paquete del runtime; si no coincide, el renderizado puede fallar.
  2. La URL de preload y la URL que se pasa a RuntimeLoader.setWasmUrl() deben ser idénticas; de lo contrario, el archivo se descarga dos veces.
  3. Sírvelo como application/wasm.
  4. Si está en otro origen, envía Access-Control-Allow-Origin.
  5. Guarda en caché el archivo versionado como inmutable y asegúrate de que tu CDN comprima los archivos .wasm.

La trampa de la regla 1 es saber qué versión usar. El WASM viene en @rive-app/webgl2 (o @rive-app/canvas), y el paquete de React fija esa dependencia a su propia versión: @rive-app/react-webgl2 4.37.1 instala @rive-app/webgl2 2.45.1. Consulta la versión en node_modules/@rive-app/webgl2/package.json y copia el archivo con ese número en el nombre:

mkdir -p public/rive
cp node_modules/@rive-app/webgl2/rive.wasm public/rive/rive-2.45.1.wasm

Repite esto en el mismo commit que cada actualización de Rive. En el componente de la mascota de arriba, setWasmUrl apunta el runtime a ese archivo, y preload() de React con as: "fetch" agrega el <link rel="preload"> correspondiente al head. React exige crossOrigin para las precargas de tipo fetch, y Next.js documenta estas sugerencias de recursos (resource hints) para Client Components.

El runtime también tiene una URL de respaldo en jsDelivr que intenta usar si el WASM principal no se carga. Si una Content Security Policy estricta solo permite tu propio origen, llama a RuntimeLoader.setWasmFallbackUrl(null) o aloja también el respaldo por tu cuenta.

Ten expectativas realistas sobre el LCP: un canvas no es candidato a LCP (web.dev enumera imágenes, video, imágenes de fondo y bloques de texto). Precargar hace que la mascota aparezca antes, pero tu elemento LCP suele ser el titular o una imagen de póster. Omite las precargas en páginas donde la mascota esté debajo del pliegue.

¿Rive funciona con output: 'export'?

Sí. Una exportación estática prerenderiza los Client Components a HTML durante el build y copia public/ en out/, así que el .riv y el WASM autoalojado se publican con el sitio, y Rive arranca en el navegador como siempre. Se aplica la misma regla: accede a window solo después de que el componente se monte.

Cambian dos cosas. Primero, la opción headers de next.config.js no es compatible con una exportación estática, así que configura el content type application/wasm y el header de caché en tu hosting. Segundo, si despliegas bajo un basePath, como en un sitio de proyecto de GitHub Pages, Next.js lo aplica a los enlaces y espera que tú lo agregues a las fuentes de next/image. Las cadenas simples, como el src de Rive y la URL del WASM, también necesitan el prefijo, por ejemplo /my-repo/mascot.riv.

Agrega una mascota de marca hecha con Rive a un proyecto con el App Router de Next.js

  1. Instala el runtime. Ejecuta npm install @rive-app/react-webgl2 y anota la versión de @rive-app/webgl2 que instala.
  2. Agrega los archivos. Coloca el .riv en public/ y copia node_modules/@rive-app/webgl2/rive.wasm a public/rive/ con la versión en el nombre.
  3. Crea un Client Component. Empieza el archivo con "use client", llama a RuntimeLoader.setWasmUrl() y renderiza el RiveComponent de useRive dentro de un contenedor de tamaño fijo.
  4. Vincula el view model. Usa autoBind: true con useViewModelInstanceNumber y useViewModelInstanceTrigger para definir el estado de ánimo y disparar reacciones.
  5. Pasa los datos del servidor. Obtén los datos en la página del Server Component y pasa el estado de ánimo al Client Component como una prop numérica.
  6. Protege las API del navegador. Lee window y matchMedia solo dentro de efectos o de useSyncExternalStore, y usa next/dynamic con ssr: false solo si alguna dependencia lo necesita.

Preguntas frecuentes

¿Necesito "use client" en cada archivo que usa Rive?

No. La directiva marca un límite: todo lo que el archivo marcado importa y renderiza directamente pasa a ser código de cliente. Ponla al principio del componente de la mascota e importa ese componente desde los Server Components. Los componentes hijos que importa no necesitan su propia directiva.

¿Qué rive.wasm corresponde a @rive-app/react-webgl2?

El que está dentro del paquete de runtime del que depende. @rive-app/react-webgl2 fija @rive-app/webgl2 a una versión exacta; por ejemplo, la 4.37.1 fija la 2.45.1. Copia node_modules/@rive-app/webgl2/rive.wasm y usa esa versión en el nombre del archivo. Para @rive-app/react-canvas, usa @rive-app/canvas en su lugar.

¿Por qué mi canvas de Rive es invisible después de la hidratación en Next.js?

Normalmente el contenedor no tiene altura. RiveComponent toma el tamaño de su elemento padre, y el canvas renderizado en el servidor empieza con tamaño cero, así que un contenedor que solo tiene ancho, o un hijo flex que se colapsa, no muestra nada. Dale al contenedor un ancho y una altura explícitos o una relación de aspecto.

Respuestas rápidas de esta guía

Fuentes

Guías relacionadas