Lottie animations are the best way to ship high-quality motion in a Next.js app. This guide covers server-side rendering gotchas, client component setup, and performance optimization â all the things that trip people up when using lottie-react with Next.js's App Router.
The SSR Problem (and How to Solve It)
lottie-react uses browser APIs that don't exist in Node.js. If you import it in a Server Component or without proper guarding, you'll get:
ReferenceError: document is not defined
The fix: mark your animation component as a Client Component and/or use dynamic import with ssr: false.
Step 0: Preview Before You Build
Before adding any .json or .lottie file, preview it in IconKing:
- See exact colors, timing, and layer structure
- Edit colors to match your Tailwind/design system
- Convert
.jsonâ.lottieformat (75% smaller) - Catch broken After Effects effects before runtime
Free, no login required.
Step 1: Install lottie-react
npm install lottie-react
Step 2: Create a Client Component
In Next.js App Router, create a dedicated client component for your animation:
// components/LottieAnimation.tsx
'use client'
import Lottie from 'lottie-react'
import loadingAnimation from '@/public/animations/loading.json'
export default function LoadingAnimation() {
return (
<Lottie
animationData={loadingAnimation}
loop={true}
style={{ width: 200, height: 200 }}
/>
)
}
Then use it anywhere â Server or Client Component:
// app/page.tsx (Server Component is fine)
import LoadingAnimation from '@/components/LottieAnimation'
export default function Page() {
return (
<main>
<LoadingAnimation />
</main>
)
}
Step 3: Dynamic Import (Alternative Approach)
If you can't mark the component as 'use client', use next/dynamic with ssr: false:
// app/page.tsx
import dynamic from 'next/dynamic'
const LottieAnimation = dynamic(
() => import('@/components/LottieAnimation'),
{ ssr: false }
)
export default function Page() {
return <LottieAnimation />
}
Storing Animation Files
Two good options:
Option A: Import JSON directly (bundled)
import animationData from '@/public/animations/loading.json'
// or
import animationData from '@/animations/loading.json'
Option B: Fetch from /public at runtime
'use client'
import { useState, useEffect } from 'react'
import Lottie from 'lottie-react'
export default function LazyAnimation() {
const [animData, setAnimData] = useState(null)
useEffect(() => {
fetch('/animations/loading.json')
.then(r => r.json())
.then(setAnimData)
}, [])
if (!animData) return null
return <Lottie animationData={animData} loop />
}
Option B reduces initial bundle size â good for large or rarely-seen animations.
Loop and Autoplay
// Loop forever (default)
<Lottie animationData={anim} loop={true} />
// Play once
<Lottie animationData={anim} loop={false} />
// Loop N times â use onComplete + state
Programmatic Control with lottieRef
'use client'
import { useRef } from 'react'
import Lottie, { LottieRefCurrentProps } from 'lottie-react'
import anim from '@/public/animations/anim.json'
export default function ControlledAnimation() {
const lottieRef = useRef<LottieRefCurrentProps>(null)
return (
<div>
<Lottie
lottieRef={lottieRef}
animationData={anim}
autoplay={false}
loop={false}
/>
<button onClick={() => lottieRef.current?.play()}>Play</button>
<button onClick={() => lottieRef.current?.pause()}>Pause</button>
<button onClick={() => lottieRef.current?.stop()}>Stop</button>
<button onClick={() => lottieRef.current?.setSpeed(2)}>2x Speed</button>
</div>
)
}
Speed and Direction
lottieRef.current?.setSpeed(0.5) // half speed
lottieRef.current?.setSpeed(2) // double speed
lottieRef.current?.setDirection(1) // forward
lottieRef.current?.setDirection(-1) // reverse
Listening for Events
<Lottie
animationData={anim}
loop={false}
onComplete={() => console.log('done')}
onLoopComplete={() => console.log('loop done')}
onEnterFrame={({ currentTime }) => console.log(currentTime)}
/>
Using dotLottie (.lottie) Format
For smaller files, use @lottiefiles/dotlottie-react:
npm install @lottiefiles/dotlottie-react
'use client'
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
export default function DotLottieAnimation() {
return (
<DotLottieReact
src="/animations/loading.lottie"
loop
autoplay
style={{ width: 200, height: 200 }}
/>
)
}
dotLottie files are ~75% smaller than JSON â significant for Core Web Vitals. Convert your .json to .lottie at IconKing.
Performance in Next.js
1. Use dotLottie format
75% smaller = faster LCP and better Lighthouse scores. Convert at IconKing.
2. Lazy load with dynamic import
const HeavyAnimation = dynamic(
() => import('@/components/HeavyAnimation'),
{ ssr: false, loading: () => <div className="h-48 w-48 bg-gray-100 animate-pulse" /> }
)
3. Pause off-screen with IntersectionObserver
'use client'
import { useRef, useEffect } from 'react'
import Lottie, { LottieRefCurrentProps } from 'lottie-react'
import anim from '@/public/animations/anim.json'
export default function PausedWhenHidden() {
const containerRef = useRef<HTMLDivElement>(null)
const lottieRef = useRef<LottieRefCurrentProps>(null)
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
entry.isIntersecting
? lottieRef.current?.play()
: lottieRef.current?.pause()
})
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return (
<div ref={containerRef}>
<Lottie lottieRef={lottieRef} animationData={anim} />
</div>
)
}
Complete Example: Form Submit Animation
'use client'
import { useState, useRef } from 'react'
import Lottie, { LottieRefCurrentProps } from 'lottie-react'
import loadingAnim from '@/public/animations/loading.json'
import successAnim from '@/public/animations/success.json'
type Status = 'idle' | 'loading' | 'success'
export default function SubmitForm() {
const [status, setStatus] = useState<Status>('idle')
const lottieRef = useRef<LottieRefCurrentProps>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('loading')
await new Promise(r => setTimeout(r, 2000)) // simulated API call
setStatus('success')
}
return (
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Your email" required />
{status === 'idle' && (
<button type="submit">Subscribe</button>
)}
{status === 'loading' && (
<Lottie animationData={loadingAnim} loop style={{ width: 48, height: 48 }} />
)}
{status === 'success' && (
<Lottie
lottieRef={lottieRef}
animationData={successAnim}
loop={false}
style={{ width: 48, height: 48 }}
onComplete={() => setStatus('idle')}
/>
)}
</form>
)
}
Common Issues in Next.js
document is not defined
You're importing lottie-react in a Server Component. Add 'use client' to the component file or use dynamic(() => import(...), { ssr: false }).
Hydration mismatch
The server renders nothing (or a placeholder), but the client renders the animation. Use a loading state or Suspense boundary to keep them in sync.
Animation file is too large
Switch to .lottie format. Convert at IconKing â files are typically 75% smaller.
Animation looks different in browser vs. preview
Open the file in IconKing â it's the authoritative preview for how the animation actually renders. If it looks wrong there, fix it at the source before debugging your Next.js setup.
Placing Animation Files in Next.js
| Location | Use case |
|---|---|
public/animations/ |
Fetched at runtime, not bundled |
src/animations/ or src/assets/
|
Imported and bundled with your JS |
For frequently-used animations (loading spinners, button states), bundle them. For large or rarely-seen animations, serve from public/.
Summary
- Mark Lottie components as
'use client'â they need browser APIs - Use
dynamic(() => ..., { ssr: false })as an alternative - Use
lottieReffor programmatic play/pause/seek - Listen to
onCompletefor chaining state transitions - Switch to
.lottieformat at IconKing for 75% smaller files - Lazy load and pause off-screen for Core Web Vitals
Top comments (0)