8 min read
Using Lottie Safely in Server-Rendered React and Next.js Apps
Lottie players (lottie-web, @lottiefiles/dotlottie-web) are built entirely around browser APIs — document, canvas, requestAnimationFrame — none of which exist in the Node.js environment that renders the initial HTML in Next.js, Remix, or any other SSR framework. Importing or initializing one at the wrong point in the render tree throws immediately.
The failure you'll actually see
ReferenceError: document is not defined
at Object.<anonymous> (node_modules/lottie-web/build/player/lottie.js:...)This happens when the library is imported (even just imported, not necessarily called) somewhere that gets evaluated on the server — typically a component without 'use client', or a client component that isn't isolated from SSR by the framework.
Fix 1: dynamic import with SSR disabled
In Next.js, next/dynamic with ssr: false tells the framework to skip this component entirely during server rendering and mount it only on the client.
import dynamic from 'next/dynamic';
const LottiePlayer = dynamic(() => import('./LottiePlayer'), {
ssr: false,
loading: () => <div className="lottie-placeholder" />,
});Fix 2: only touch the player inside useEffect
If you'd rather not disable SSR for the whole component, mark it 'use client' and make sure the player is only created inside useEffect, which never runs during server rendering — never call lottie.loadAnimation() in the component body or in a variable initializer.
'use client';
function LottiePlayer({ src }: { src: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let anim: AnimationItem | undefined;
import('lottie-web').then(({ default: lottie }) => {
anim = lottie.loadAnimation({
container: containerRef.current!,
path: src,
renderer: 'svg',
loop: true,
autoplay: true,
});
});
return () => anim?.destroy();
}, [src]);
return <div ref={containerRef} />;
}Avoid hydration mismatches from animation state
A subtler issue: don't render text or markup derived from the animation's runtime state (current frame, computed duration) during the initial render. The server has no animation instance to read that from, so it renders one thing, the client renders another once the animation mounts, and React logs a hydration mismatch. Keep animation-derived UI behind the same useEffect/client-only boundary as the player itself.