DEV Community

Fazal Shah
Fazal Shah

Posted on

How to Use Lottie Animations in Next.js (2025 Guide)

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
Enter fullscreen mode Exit fullscreen mode

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 → .lottie format (75% smaller)
  • Catch broken After Effects effects before runtime

Free, no login required.


Step 1: Install lottie-react

npm install lottie-react
Enter fullscreen mode Exit fullscreen mode

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 }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Listening for Events

<Lottie
  animationData={anim}
  loop={false}
  onComplete={() => console.log('done')}
  onLoopComplete={() => console.log('loop done')}
  onEnterFrame={({ currentTime }) => console.log(currentTime)}
/>
Enter fullscreen mode Exit fullscreen mode

Using dotLottie (.lottie) Format

For smaller files, use @lottiefiles/dotlottie-react:

npm install @lottiefiles/dotlottie-react
Enter fullscreen mode Exit fullscreen mode
'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 }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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" /> }
)
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Mark Lottie components as 'use client' — they need browser APIs
  2. Use dynamic(() => ..., { ssr: false }) as an alternative
  3. Use lottieRef for programmatic play/pause/seek
  4. Listen to onComplete for chaining state transitions
  5. Switch to .lottie format at IconKing for 75% smaller files
  6. Lazy load and pause off-screen for Core Web Vitals

Top comments (0)