Frameworks

GSAP with SSR

SSR-safe GSAP is the set of patterns that let GSAP run inside a server-rendered React app like Next.js without crashing the build or flashing on hydration. GSAP needs the DOM and `window`, neither of which exist during server render, so the rules come down to keeping every GSAP call inside client-only lifecycle (`useGSAP`), targeting real nodes with refs, and making sure the server HTML already matches the animation's start state.

Updated July 1, 2026

Mechanics

Why GSAP needs handling under SSR

In a Next.js App Router app, components render on the server by default. GSAP reads and writes the DOM and reaches for window, so any gsap.* or ScrollTrigger.* call that runs during server render throws window is not defined and fails the build. The fix is to keep the animation in a 'use client' leaf and run all GSAP code inside useGSAP, which fires in a client-only useLayoutEffect after hydration.

Reveal.jsx

The page itself stays a Server Component. Only the animated leaf gets 'use client', so you keep server rendering everywhere else and pay the client cost on the one component that actually animates.

Use a ref for the scope and let selectors resolve against that container. document.querySelector returns nothing during server render and, once mounted, matches across the whole page instead of just this component. A scoped ref keeps the selectors inside the tree GSAP owns.

Register plugins client-side

Registering a plugin at module top level runs on import, which happens during SSR. gsap.registerPlugin(useGSAP) is safe, but a plugin that touches window on register (ScrollTrigger does) needs a guard: if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);. Skipping the guard is the most common reason a Next.js build breaks the moment you add ScrollTrigger.
Hydration

Killing the flash before the animation runs

useGSAP runs after hydration, so there is a moment where the server HTML is on screen but the animation hasn't started. If you animate with gsap.from({ opacity: 0 }), the element renders fully visible on the server, then GSAP snaps it to hidden and reveals it, so the user sees a flash. This is not a React hydration mismatch, the DOM structure matches fine, it's a paint-order problem.

Park the start state in CSS so the server already renders the element in its pre-animation pose. Then animate with gsap.to out of that pose. The server HTML and the animation's first frame agree, so nothing jumps.

styles.css

A real React hydration mismatch is a different failure. It happens when you branch the returned markup on typeof window or a useState-after-mount flag, so the server renders one tree and the client renders another. Never gate the JSX you return, gate the behavior inside useGSAP. Render the same tree on both sides and let GSAP change it after mount.

When

Reach for these patterns when

  • Any GSAP animation inside a Next.js App Router or Pages Router app
  • A ScrollTrigger, SplitText, or Flip animation that has to survive a production build (next build)
  • Server-rendered marketing pages where the animated section is one client leaf inside an otherwise-static page
  • Entrance reveals where a flash between server paint and animation start would be visible
Alternatives

Use something else when

  • The whole page is already 'use client' and never server-rendered, then a plain useGSAP with no extra SSR guards is enough
  • You only need a simple hover or enter transition, native CSS transition needs no client boundary and ships zero JS
  • You're in a fully client-side SPA (Vite, CRA) with no server render, typeof window guards are unnecessary there
  • You want to render markup conditionally on device or viewport, resolve that after mount with a hydration-safe hook, not by branching the SSR tree
In production

Used in these Annnimate components

Every Annnimate React component ships as a 'use client' leaf that runs its GSAP inside useGSAP with a scoped container ref, so it drops into a Next.js Server Component page without a build error or a hydration flash. Two that lean on the parked-start-state pattern:

  • Text Reveal parks each line hidden in CSS and animates it out with gsap.to on mount, so the server HTML matches the first frame and there is no flash before the reveal runs
  • Element Reveal scopes its selectors to a container ref and runs entirely inside useGSAP, so it renders identically on server and client and only animates after hydration

GSAP runs on around 2.2% of all websites (w3techs, June 2026), and React sits at 69.9% developer usage against Vue's 44.8% (State of JS 2025). The React and Next.js audience is where most GSAP-in-SSR questions come from, which is why every Annnimate React export assumes a server-rendered host.

Used in components

See it running in production

FAQ

Common questions

Why does my GSAP animation crash the Next.js build with 'window is not defined'?
A GSAP or ScrollTrigger call is running during server render. Registering a window-touching plugin at module top level, or calling gsap.* outside a client lifecycle, executes on the server where window doesn't exist. Move all GSAP code into useGSAP inside a 'use client' component, and guard ScrollTrigger registration with if (typeof window !== 'undefined').
Does useGSAP cause hydration mismatches?
No. useGSAP runs in a client-only useLayoutEffect after hydration, so it never changes what the server renders. Hydration mismatches come from branching the returned JSX on typeof window or a post-mount state flag. Render the same tree on server and client, and let useGSAP mutate it after mount.
How do I stop the flash before the animation starts?
Park the animation's start state in CSS (opacity: 0, a transform) so the server HTML already renders in the pre-animation pose, then animate out of it with gsap.to. Using gsap.from instead renders the element visible first, then hides it, which is what produces the flash.
Do I need 'use client' on every component to use GSAP?
No. Only the component that actually runs GSAP needs 'use client'. Keep the page a Server Component and isolate the animation in a client leaf. That keeps server rendering everywhere else and limits the client bundle to the one component that animates.
Can I use document.querySelector instead of a ref for targets?
It works after mount but it isn't scoped, so the selector can match elements outside your component and it returns nothing during server render. Pass a container ref as the scope to useGSAP and use class selectors inside it, or ref the nodes directly. That keeps targeting predictable across re-renders and SSR.