Skip to content
charmotion

Rive in the Next.js App Router: Client Components, SSR and WASM

By the Charmotion team · Updated

Cover illustration for the guide: Rive in the Next.js App Router: Client Components, SSR and WASM, with Charmotion demo characters

In the Next.js App Router, render Rive from a Client Component marked "use client" and import it into your Server Component page. Client Components still prerender on the server, so touch window only in effects. Keep the .riv in public/, pass server data such as the mood as a number prop, and self-host rive.wasm at the exact runtime version.

Key takeaways

  • Pages and layouts are Server Components; the Rive component must be a Client Component.
  • Client Components are prerendered to HTML, so browser globals belong in effects or useSyncExternalStore.
  • next/dynamic with ssr: false only works inside a Client Component.
  • Server data crosses the boundary as serializable props, such as a mood number.
  • The WASM version to self-host is the one of @rive-app/webgl2 or @rive-app/canvas inside node_modules, not the React package's.

Already have a character or logo? See your character in real app screens: splash, onboarding, loading and empty-state screens. It's free, runs in your browser and uploads nothing.

Where does Rive run in the App Router?

In the browser. App Router pages and layouts are Server Components by default, and Server Components can't use state, effects or browser APIs. Rive needs all three: useRive is a hook, and the runtime draws to a canvas with WebAssembly. So the component that calls useRive must be a Client Component, marked with "use client" at the top of its file.

Keep that boundary small. Next.js puts everything a "use client" file imports into the client bundle, so mark the mascot component, not the page or layout around it. The page stays a Server Component and simply renders <Mascot />.

One detail surprises people: Client Components are not browser-only. Next.js prerenders them to HTML on the server, then hydrates them in the browser. When we server-rendered @rive-app/react-webgl2 4.37.1 with React 19, it produced an empty, zero-size canvas without errors; the animation starts after hydration. Reserve the box size in CSS so nothing shifts when it appears.

The general React setup (packages, useRive, inputs vs view model hooks) is in Rive in React and Next.js. This page covers what is specific to the App Router.

Why do I get "window is not defined" with Rive?

Because some code that needs the browser ran during server prerendering. Since current Rive React packages import and prerender cleanly, the cause is usually in your own component:

  • Reading window, matchMedia or localStorage during render, including in a useState initializer.
  • Creating new Rive() from @rive-app/webgl2 or @rive-app/canvas at module scope or during render, instead of in useEffect.
  • Calling RuntimeLoader.awaitInstance() at module scope, which starts loading WASM on the server.
  • An older wrapper library that touches window when it is imported.

Move browser work into useEffect or event handlers, which never run on the server. For values you need during render, such as the reduced-motion preference, useSyncExternalStore takes a separate server snapshot, which also avoids a 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
  );
}

When the preference is reduce, render a still poster instead of the canvas; that also skips the runtime download. If the error comes from a dependency you can't change, load the component with ssr: false, below.

When should I use next/dynamic with ssr: false?

Use it when a dependency touches browser globals as soon as it is imported, or when the animation sits below the fold and you want its JavaScript out of the initial bundle. Next.js only allows ssr: false in Client Components; in a Server Component it raises an error. So wrap it in its own "use client" file:

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

The trade-off is that the server sends no HTML for that part of the page. The loading placeholder fills the gap, so give it the same width and height as the canvas, and use the character's resting pose, so the swap is invisible.

For a plain mascot, you usually don't need this: a "use client" component that keeps browser work inside effects prerenders fine.

How do I drive the mood from server data?

Fetch in the Server Component and pass the result down as a prop. Next.js requires props passed to Client Components to be serializable, which a number is, so the mood crosses the boundary cleanly:

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

The client component sets the view model number whenever the prop changes, and fires a one-shot reaction when a counter goes up. It also self-hosts the WASM, covered in the next section:

// 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 notes that its property hooks run after the file loads, so the first frame renders with the values saved in the file. If the opening frame must already show the server mood, set it in useRive's onRiveReady callback, which runs before the state machine advances.

Triggers are events, not state, so they don't come from the server directly. After a Server Action resolves, bump a counter in client state, as in this form:

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

The mood numbers here follow a Charmotion file, whose Character view model has mood 0 to 9 (0 idle, 1 happy, 4 sad, 5 loading and so on) and success, error, yes, no, enter and exit triggers. With your own file, rename the properties to match.

loading: mood 5, loops while work is in progress.

How do I self-host rive.wasm and preload it?

By default the runtime downloads rive.wasm from unpkg when the first Rive instance starts. Rive's docs list reasons to host it yourself instead (control over the origin, faster start, your own caching policy) and five rules if you do:

  1. The WASM version must match the runtime package exactly; a mismatch can break rendering.
  2. The preload URL and the URL passed to RuntimeLoader.setWasmUrl() must be identical, or the file downloads twice.
  3. Serve it as application/wasm.
  4. If it is on another origin, send Access-Control-Allow-Origin.
  5. Cache the versioned file as immutable and make sure your CDN compresses .wasm.

The trap in rule 1 is which version. The WASM ships in @rive-app/webgl2 (or @rive-app/canvas), and the React package pins that dependency to its own version: @rive-app/react-webgl2 4.37.1 installs @rive-app/webgl2 2.45.1. Read the version from node_modules/@rive-app/webgl2/package.json and copy the file with that number in its name:

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

Redo this in the same commit as every Rive upgrade. In the mascot component above, setWasmUrl points the runtime at that file, and React's preload() with as: "fetch" adds the matching <link rel="preload"> to the head. React requires crossOrigin for fetch preloads, and Next.js documents these resource hints for Client Components.

The runtime also has a fallback URL on jsDelivr that it tries if the primary WASM fails to load. If a strict Content Security Policy only allows your own origin, call RuntimeLoader.setWasmFallbackUrl(null) or self-host the fallback too.

Keep expectations right about LCP: a canvas isn't an LCP candidate (web.dev lists images, video, background images and text blocks). Preloading makes the mascot appear sooner, but your LCP element is usually the headline or a poster image. Skip the preloads on pages where the mascot is below the fold.

Does Rive work with output: 'export'?

Yes. A static export prerenders Client Components to HTML at build time and copies public/ into out/, so the .riv and the self-hosted WASM ship with the site, and Rive starts in the browser as usual. The same rule applies: touch window only after the component mounts.

Two things change. First, the headers option in next.config.js isn't supported in a static export, so set the application/wasm content type and the cache header on your host instead. Second, if you deploy under a basePath, such as a GitHub Pages project site, Next.js applies it to links and expects you to add it to next/image sources yourself. Plain strings like the Rive src and the WASM URL need the prefix too, for example /my-repo/mascot.riv.

Add a Rive mascot to a Next.js App Router project

  1. Install the runtime. Run npm install @rive-app/react-webgl2 and note the version of @rive-app/webgl2 it installs.
  2. Add the files. Put the .riv in public/ and copy node_modules/@rive-app/webgl2/rive.wasm to public/rive/ with the version in its name.
  3. Create a Client Component. Start the file with "use client", call RuntimeLoader.setWasmUrl(), and render useRive's RiveComponent in a fixed-size wrapper.
  4. Bind the view model. Use autoBind: true with useViewModelInstanceNumber and useViewModelInstanceTrigger to set the mood and fire reactions.
  5. Pass server data. Fetch in the Server Component page and pass the mood to the Client Component as a number prop.
  6. Guard browser APIs. Read window and matchMedia only in effects or useSyncExternalStore, and use next/dynamic with ssr: false only if a dependency needs it.

Frequently asked questions

Do I need "use client" in every file that uses Rive?

No. The directive marks a boundary: everything the marked file imports and renders directly becomes client code. Put it at the top of the mascot component and import that component from Server Components. Child components it imports don't need their own directive.

Which rive.wasm matches @rive-app/react-webgl2?

The one inside the runtime package it depends on. @rive-app/react-webgl2 pins @rive-app/webgl2 to an exact version, for example 4.37.1 pins 2.45.1. Copy node_modules/@rive-app/webgl2/rive.wasm and use that version in the file name. For @rive-app/react-canvas, use @rive-app/canvas instead.

Why is my Rive canvas invisible after hydration in Next.js?

Usually the wrapper has no height. RiveComponent sizes itself to its parent, and the server-rendered canvas starts at zero size, so a wrapper with only a width, or a flex child that collapses, shows nothing. Give the wrapper an explicit width and height or an aspect ratio.

Quick answers from this guide

Sources

Related guides