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
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.
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
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.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.
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.
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
Use something else when
- The whole page is already
'use client'and never server-rendered, then a plainuseGSAPwith no extra SSR guards is enough - You only need a simple hover or enter transition, native CSS
transitionneeds no client boundary and ships zero JS - You're in a fully client-side SPA (Vite, CRA) with no server render,
typeof windowguards 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
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.toon 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.
See it running in production
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 wherewindowdoesn't exist. Move all GSAP code intouseGSAPinside a'use client'component, and guard ScrollTrigger registration withif (typeof window !== 'undefined'). - Does useGSAP cause hydration mismatches?
- No.
useGSAPruns in a client-onlyuseLayoutEffectafter hydration, so it never changes what the server renders. Hydration mismatches come from branching the returned JSX ontypeof windowor a post-mount state flag. Render the same tree on server and client, and letuseGSAPmutate 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 withgsap.to. Usinggsap.frominstead 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
scopetouseGSAPand use class selectors inside it, or ref the nodes directly. That keeps targeting predictable across re-renders and SSR.
onMounted once the DOM exists, scope them to the component with a template ref, and kill them in onUnmounted.NextautoAlphaautoAlpha is a GSAP shorthand that tweens opacity and visibility together.