कंटेंट पर जाएँ
charmotion

Next.js App Router में Rive: Client Components, SSR और WASM

Charmotion टीम द्वारा · अपडेट:

गाइड का कवर इलस्ट्रेशन: Next.js App Router में Rive: Client Components, SSR और WASM, Charmotion डेमो कैरेक्टर के साथ

Next.js App Router में Rive को "use client" वाले Client Component से render करें और उसे अपने Server Component पेज में import करें। Client Components भी server पर prerender होते हैं, इसलिए window को सिर्फ़ effects में ही छुएँ। .riv को public/ में रखें, मूड जैसे server data को number prop के रूप में पास करें, और rive.wasm को बिल्कुल उसी runtime version पर self-host करें।

मुख्य बातें

  • Pages और layouts, Server Components होते हैं; Rive component का Client Component होना ज़रूरी है।
  • Client Components HTML में prerender होते हैं, इसलिए browser globals का उपयोग effects या useSyncExternalStore में ही करें।
  • next/dynamic के साथ ssr: false सिर्फ़ Client Component के अंदर काम करता है।
  • Server data, serializable props के रूप में boundary पार करता है, जैसे मूड का नंबर।
  • self-host करने के लिए WASM का वही version चाहिए जो node_modules के अंदर @rive-app/webgl2 या @rive-app/canvas का है, React package का नहीं।

पहले से कोई कैरेक्टर या लोगो है? अपने कैरेक्टर को असली ऐप स्क्रीन में देखें: स्प्लैश, ऑनबोर्डिंग, लोडिंग और एम्प्टी स्टेट स्क्रीन। यह फ़्री है, आपके ब्राउज़र में चलता है और कुछ भी अपलोड नहीं होता।

App Router में Rive कहाँ चलता है?

ब्राउज़र में। App Router के pages और layouts डिफ़ॉल्ट रूप से Server Components होते हैं, और Server Components में state, effects या browser APIs का उपयोग नहीं हो सकता। Rive को तीनों चाहिए: useRive एक hook है, और runtime WebAssembly से canvas पर ड्रॉ करता है। इसलिए जो component useRive को कॉल करता है, उसका Client Component होना ज़रूरी है, जिसकी फ़ाइल में सबसे ऊपर "use client" लिखा हो।

इस boundary को छोटा रखें। "use client" वाली फ़ाइल जो कुछ भी import करती है, Next.js उसे client bundle में डाल देता है, इसलिए मैस्कॉट component को mark करें, उसके आसपास के page या layout को नहीं। Page एक Server Component ही रहता है और बस <Mascot /> render करता है।

एक बात लोगों को हैरान करती है: Client Components सिर्फ़ ब्राउज़र में नहीं चलते। Next.js उन्हें server पर HTML में prerender करता है, फिर ब्राउज़र में hydrate करता है। जब हमने React 19 के साथ @rive-app/react-webgl2 4.37.1 को server-render किया, तो उसने बिना किसी error के एक खाली, zero-size canvas बनाया; एनिमेशन hydration के बाद शुरू होता है। CSS में box का साइज़ पहले से रिज़र्व करें, ताकि दिखाई देने पर कुछ भी खिसके नहीं।

सामान्य React setup (packages, useRive, inputs बनाम view model hooks) React और Next.js में Rive में है। यह पेज वही बातें बताता है जो App Router के लिए खास हैं।

Rive के साथ "window is not defined" error क्यों आता है?

क्योंकि ब्राउज़र पर निर्भर कोई code server prerendering के दौरान चल गया। मौजूदा Rive React packages ठीक से import और prerender हो जाते हैं, इसलिए वजह आमतौर पर आपके अपने component में होती है:

  • render के दौरान window, matchMedia या localStorage पढ़ना, useState initializer में भी।
  • @rive-app/webgl2 या @rive-app/canvas से new Rive() को useEffect में बनाने के बजाय module scope पर या render के दौरान बनाना।
  • module scope पर RuntimeLoader.awaitInstance() कॉल करना, जिससे server पर WASM लोड होना शुरू हो जाता है।
  • कोई पुरानी wrapper library, जो import होते ही window को छूती है।

ब्राउज़र वाला काम useEffect या event handlers में ले जाएँ, जो server पर कभी नहीं चलते। जिन values की ज़रूरत render के दौरान होती है, जैसे reduced-motion preference, उनके लिए useSyncExternalStore एक अलग server snapshot लेता है, जिससे 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
  );
}

जब preference reduce हो, तो canvas की जगह एक स्थिर poster render करें; इससे runtime download भी नहीं होता। अगर error किसी ऐसी dependency से आ रहा है जिसे आप बदल नहीं सकते, तो component को ssr: false के साथ लोड करें, जैसा नीचे बताया गया है।

next/dynamic के साथ ssr: false का उपयोग कब करें?

इसका उपयोग तब करें जब कोई dependency import होते ही browser globals को छूती हो, या जब एनिमेशन fold के नीचे हो और आप उसका JavaScript शुरुआती bundle से बाहर रखना चाहते हों। Next.js सिर्फ़ Client Components में ssr: false की अनुमति देता है; Server Component में यह error देता है। इसलिए इसे उसकी अपनी "use client" फ़ाइल में wrap करें:

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

नुकसान यह है कि server, पेज के उस हिस्से के लिए कोई HTML नहीं भेजता। loading placeholder उस खाली जगह को भरता है, इसलिए उसे canvas जितनी ही width और height दें, और कैरेक्टर का resting pose इस्तेमाल करें, ताकि बदलाव दिखाई न दे।

साधारण मैस्कॉट के लिए आमतौर पर इसकी ज़रूरत नहीं होती: जो "use client" component ब्राउज़र वाला काम effects के अंदर रखता है, वह ठीक से prerender हो जाता है।

server data से मूड कैसे चलाएँ?

Server Component में fetch करें और नतीजे को prop के रूप में नीचे पास करें। Next.js के लिए ज़रूरी है कि Client Components को दिए गए props serializable हों, और नंबर serializable होता है, इसलिए मूड आसानी से boundary पार कर लेता है:

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

client component हर बार prop बदलने पर व्यू मॉडल का नंबर सेट करता है, और counter बढ़ने पर वन-शॉट रिएक्शन (एक बार चलने वाला रिएक्शन) फ़ायर करता है। यह WASM को self-host भी करता है, जिसके बारे में अगले सेक्शन में बताया गया है:

// 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 बताता है कि उसके property hooks file लोड होने के बाद चलते हैं, इसलिए पहला फ़्रेम फ़ाइल में सेव की गई values के साथ render होता है। अगर शुरुआती फ़्रेम में ही server वाला मूड दिखना चाहिए, तो उसे useRive के onRiveReady callback में सेट करें, जो स्टेट मशीन के आगे बढ़ने से पहले चलता है।

ट्रिगर events होते हैं, state नहीं, इसलिए वे सीधे server से नहीं आते। Server Action resolve होने के बाद client state में एक counter बढ़ाएँ, जैसा इस 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>
  );
}

यहाँ मूड नंबर एक Charmotion file के हिसाब से हैं, जिसके Character व्यू मॉडल में mood 0 से 9 तक है (0 idle, 1 happy, 4 sad, 5 loading आदि) और success, error, yes, no, enter और exit ट्रिगर हैं। अपनी फ़ाइल के साथ properties के नाम उसके हिसाब से बदलें।

loading: mood 5, काम चलने तक लूप होता है।

rive.wasm को self-host और preload कैसे करें?

डिफ़ॉल्ट रूप से पहला Rive instance शुरू होने पर runtime rive.wasm को unpkg से डाउनलोड करता है। Rive के docs इसे खुद होस्ट करने की वजहें बताते हैं (origin पर नियंत्रण, तेज़ शुरुआत, अपनी caching policy) और ऐसा करने पर पाँच नियम:

  1. WASM का version, runtime package से बिल्कुल मेल खाना चाहिए; mismatch होने पर rendering टूट सकती है।
  2. preload URL और RuntimeLoader.setWasmUrl() को दिया गया URL बिल्कुल एक जैसे होने चाहिए, वरना फ़ाइल दो बार डाउनलोड होती है।
  3. इसे application/wasm के रूप में serve करें।
  4. अगर यह किसी दूसरे origin पर है, तो Access-Control-Allow-Origin भेजें।
  5. versioned file को immutable के रूप में cache करें और पक्का करें कि आपका CDN .wasm को compress करता हो।

नियम 1 में असली पेच यह है कि कौन-सा version। WASM @rive-app/webgl2 (या @rive-app/canvas) में आता है, और React package उस dependency को अपने तय version पर pin करता है: @rive-app/react-webgl2 4.37.1, @rive-app/webgl2 2.45.1 install करता है। version को node_modules/@rive-app/webgl2/package.json से पढ़ें और फ़ाइल को कॉपी करके उसके नाम में वही नंबर जोड़ें:

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

हर Rive upgrade के साथ उसी commit में यह दोबारा करें। ऊपर वाले मैस्कॉट component में setWasmUrl runtime को उस फ़ाइल की ओर point करता है, और as: "fetch" के साथ React का preload() head में मेल खाता <link rel="preload"> जोड़ता है। React में fetch preloads के लिए crossOrigin ज़रूरी है, और Next.js ने Client Components के लिए इन resource hints को document किया है।

runtime के पास jsDelivr पर एक fallback URL भी है, जिसे वह primary WASM लोड न होने पर आज़माता है। अगर सख्त Content Security Policy सिर्फ़ आपके अपने origin की अनुमति देती है, तो RuntimeLoader.setWasmFallbackUrl(null) कॉल करें या fallback को भी self-host करें।

LCP को लेकर उम्मीदें सही रखें: canvas, LCP candidate नहीं होता (web.dev images, video, background images और text blocks को गिनाता है)। Preload करने से मैस्कॉट जल्दी दिखता है, लेकिन आपका LCP element आमतौर पर headline या poster image होता है। जिन पेजों पर मैस्कॉट fold के नीचे है, वहाँ preload न करें।

क्या Rive, output: 'export' के साथ काम करता है?

हाँ। static export, build के समय Client Components को HTML में prerender करता है और public/ को out/ में copy करता है, इसलिए .riv और self-hosted WASM साइट के साथ ही ship होते हैं, और Rive हमेशा की तरह ब्राउज़र में शुरू होता है। वही नियम लागू होता है: window को component mount होने के बाद ही छुएँ।

दो चीज़ें बदलती हैं। पहली, static export में next.config.js का headers option सपोर्ट नहीं होता, इसलिए application/wasm content type और cache header अपने host पर सेट करें। दूसरी, अगर आप basePath के तहत deploy करते हैं, जैसे GitHub Pages project site, तो Next.js इसे links पर लागू करता है और उम्मीद करता है कि next/image sources में इसे आप खुद जोड़ें। Rive src और WASM URL जैसी साधारण strings में भी prefix चाहिए, जैसे /my-repo/mascot.riv।

Next.js App Router प्रोजेक्ट में Rive मैस्कॉट जोड़ें

  1. रनटाइम इंस्टॉल करें. npm install @rive-app/react-webgl2 चलाएँ और देखें कि यह @rive-app/webgl2 का कौन सा वर्ज़न इंस्टॉल करता है।
  2. फ़ाइलें जोड़ें. .riv को public/ में रखें और node_modules/@rive-app/webgl2/rive.wasm को उसके नाम में वर्ज़न जोड़कर public/rive/ में कॉपी करें।
  3. Client Component बनाएँ. फ़ाइल की शुरुआत "use client" से करें, RuntimeLoader.setWasmUrl() कॉल करें, और useRive के RiveComponent को फ़िक्स्ड साइज़ वाले wrapper में रेंडर करें।
  4. view model बाइंड करें. मूड सेट करने और रिएक्शन फ़ायर करने के लिए autoBind: true को useViewModelInstanceNumber और useViewModelInstanceTrigger के साथ इस्तेमाल करें।
  5. सर्वर डेटा पास करें. Server Component पेज में डेटा fetch करें और मूड को number prop के रूप में Client Component को पास करें।
  6. ब्राउज़र API के लिए गार्ड लगाएँ. window और matchMedia को सिर्फ़ effects या useSyncExternalStore में पढ़ें, और next/dynamic को ssr: false के साथ तभी इस्तेमाल करें जब किसी dependency को इसकी ज़रूरत हो।

अक्सर पूछे जाने वाले सवाल

क्या Rive इस्तेमाल करने वाली हर फ़ाइल में "use client" ज़रूरी है?

नहीं। यह directive एक सीमा (boundary) तय करता है: मार्क की गई फ़ाइल जो कुछ भी सीधे import और render करती है, वह सब client code बन जाता है। इसे मैस्कॉट component में सबसे ऊपर लिखें और उस component को Server Components से import करें। इसके द्वारा import किए गए child components को अपने अलग directive की ज़रूरत नहीं होती।

कौन सा rive.wasm @rive-app/react-webgl2 से मेल खाता है?

वही जो उस runtime package के अंदर है जिस पर यह निर्भर करता है। @rive-app/react-webgl2, @rive-app/webgl2 को एक तय वर्ज़न पर pin करता है, उदाहरण के लिए 4.37.1, 2.45.1 को pin करता है। node_modules/@rive-app/webgl2/rive.wasm कॉपी करें और फ़ाइल के नाम में वही वर्ज़न इस्तेमाल करें। @rive-app/react-canvas के लिए इसकी जगह @rive-app/canvas इस्तेमाल करें।

Next.js में hydration के बाद मेरा Rive canvas दिखाई क्यों नहीं देता?

आमतौर पर wrapper की कोई height नहीं होती। RiveComponent अपना साइज़ parent के हिसाब से तय करता है, और सर्वर पर रेंडर हुआ canvas शून्य साइज़ से शुरू होता है, इसलिए सिर्फ़ width वाला wrapper, या सिकुड़ जाने वाला flex child, कुछ नहीं दिखाता। wrapper को साफ़ तौर पर width और height, या aspect ratio दें।

इस गाइड से झटपट जवाब

स्रोत

संबंधित गाइड