Motion
GSAP Next.js App Router setup: useGSAP, cleanup and SSR
A setup that survives route changes, has no flash before hydration and never throws "window is not defined".
By Nabeel Mustafa··6 min read

A solid GSAP Next.js App Router setup has four parts: register plugins once in a shared file, animate only inside client components, use the useGSAP hook with a scope so cleanup is automatic, and hide intro elements in CSS so they do not flash before hydration. Here is the shared file I use on every project.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP);
gsap.defaults({ ease: "power3.out", duration: 1 });
export { gsap, ScrollTrigger, SplitText, useGSAP };Every client component imports GSAP from this file, never from gsap directly. That way plugins are registered exactly once and defaults stay the same across the site. The rest of this post walks through each part and the errors you will hit if you skip one.
GSAP Next.js App Router setup in four steps
- Install the packages:
npm install gsap @gsap/react. - Create
lib/gsap.tsas above. Every plugin is free since GSAP 3.13, including SplitText. - Put animation code only in files that start with "use client".
- Use
useGSAPwith a ref asscope, so selectors stay inside the component and cleanup happens on unmount.
Your page stays a Server Component. It renders a client component for the part that moves, and passes content as props. This keeps the HTML server-rendered for SEO while the animation runs in the browser.

How to use useGSAP with scope and cleanup
The useGSAP hook is a drop-in for useLayoutEffect that wraps your code in a gsap.context(). Everything created inside it, tweens, timelines and ScrollTriggers, is reverted when the component unmounts. With scope: ref, a selector like ".title" only matches elements inside that ref, so two copies of the component never animate each other.
"use client";
import { useRef } from "react";
import { gsap, useGSAP } from "@/lib/gsap";
export default function Intro({ title, sub }: { title: string; sub: string }) {
const root = useRef<HTMLElement>(null);
useGSAP(
(context, contextSafe) => {
const tl = gsap.timeline({ delay: 0.1 });
tl.set(".intro", { visibility: "inherit" })
.fromTo(".intro-title", { yPercent: 110 }, { yPercent: 0, duration: 1.1 })
.fromTo(".intro-sub", { y: 20, autoAlpha: 0 }, { y: 0, autoAlpha: 1 }, "-=0.7");
// Animations created later, in an event, must be wrapped in contextSafe.
const onEnter = contextSafe!(() => gsap.to(".intro-title", { scale: 1.03, duration: 0.4 }));
const el = root.current!;
el.addEventListener("pointerenter", onEnter);
return () => el.removeEventListener("pointerenter", onEnter);
},
{ scope: root },
);
return (
<section ref={root}>
<h1 className="intro invisible overflow-hidden">
<span className="intro-title inline-block">{title}</span>
</h1>
<p className="intro intro-sub invisible">{sub}</p>
</section>
);
}Two details matter here. First, contextSafe wraps any animation that is created after the hook runs, such as in an event listener, so it is also reverted on unmount. Second, the function you return removes the listener. GSAP cleans up its own objects, but not your DOM listeners.
If the animation depends on props, pass them in dependencies. Add revertOnUpdate: true when you want the old animation reverted before the new one runs.
Why use fromTo instead of from in React
gsap.from() reads the element's current values as the end state. In React that is risky. In development, strict mode runs your hook twice. If the second run starts while the first tween is halfway, from() reads the halfway values as the end state, and the element gets stuck there. The same thing happens when a dependency changes mid-animation.
gsap.fromTo() sets both ends explicitly, so a re-run always lands in the right place. I use fromTo() for every entrance animation in React. It costs one extra object and removes a whole class of bugs.
How to stop the flash before hydration
The server sends finished HTML. The browser paints it before React hydrates and before your animation runs. So for a split second the user sees the final state, then it jumps to the start state and animates in. That is the flash.
The fix is to hide intro elements in CSS from the first paint, then reveal them from GSAP. In the component above, .intro has the Tailwind class invisible (visibility: hidden). The timeline sets visibility: "inherit" as its first step, and fades use autoAlpha, which animates opacity and toggles visibility together.
useGSAP(() => {
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: reduce)", () => {
gsap.set(".intro", { autoAlpha: 1 });
});
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.fromTo(".intro", { y: 20, autoAlpha: 0 }, { y: 0, autoAlpha: 1, stagger: 0.08 });
});
}, { scope: root });Always handle the reduced motion case. If you hide elements in CSS and only reveal them in the animated branch, people who turned on reduced motion see an empty page. gsap.matchMedia() runs the right branch and reverts it when the setting changes.
How to refresh ScrollTrigger after images, fonts and navigation
ScrollTrigger measures start and end positions when it is created. Anything that changes the page height after that makes the positions wrong: a web font that swaps in, an image without dimensions, content that loads late. Three habits fix most cases.
- Give every image a width and height (or use
next/imagewithfillinside a sized box), so the layout does not move when it loads. - Call
ScrollTrigger.refresh()once afterdocument.fonts.readyresolves. - After each route change, wait for the new layout to paint, then refresh.
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { ScrollTrigger } from "@/lib/gsap";
export default function ScrollTriggerRefresh() {
const pathname = usePathname();
useEffect(() => {
document.fonts.ready.then(() => ScrollTrigger.refresh());
}, []);
useEffect(() => {
// Two frames: the new page has rendered and painted.
const id = requestAnimationFrame(() => requestAnimationFrame(() => ScrollTrigger.refresh()));
return () => cancelAnimationFrame(id);
}, [pathname]);
return null;
}Render this once in your root layout. If you also use smooth scrolling, the same component is a good place to sync it, which I cover in Lenis smooth scroll in Next.js.
GSAP React tutorial: the same setup without Next.js
Everything above works in a plain React app too, for example one made with Vite. Two things change. There is no server rendering, so you do not need "use client" and there is no flash before hydration to hide. And you can register plugins once in your entry file, though a shared file like lib/gsap.ts works just as well.
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export function Hero() {
const root = useRef<HTMLElement>(null);
useGSAP(
() => {
gsap.fromTo(".hero-title", { y: 40, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.8, ease: "power3.out" });
},
{ scope: root },
);
return (
<section ref={root}>
<h1 className="hero-title">Hello</h1>
</section>
);
}useGSAP comes from @gsap/react and reverts the animation when the component unmounts, exactly as in Next.js, and scope keeps the .hero-title selector inside this component. If you only need GSAP in React, that is the whole setup.
Fixing common GSAP errors in Next.js
"window is not defined"
Some code touched window or document during server rendering. Usually it is a call at the top level of a module, like window.matchMedia(...) outside any hook, or GSAP code in a file without "use client". Move browser-only code inside useGSAP or useEffect, which only run in the browser. If you need a helper, guard it with typeof window !== "undefined".
ScrollTrigger not working after navigation
You go to a new page and the scroll animations either do not fire or fire at the wrong place. The usual causes are triggers created with plain useEffect and never killed, so old triggers from the previous page still exist, or positions measured before the new page had its final height. Use useGSAP so old triggers are reverted on unmount, and refresh after the route change as shown above.
Animations run twice or end in the wrong state in development
That is React strict mode running effects twice. useGSAP handles it, but from() tweens can still capture a halfway state. Switch them to fromTo().
Wrapping up
Register plugins once, keep GSAP in client components, use useGSAP with a scope, prefer fromTo(), hide intros in CSS and refresh ScrollTrigger when the layout changes. That covers almost every problem I see in GSAP and Next.js projects. If you want to skip the setup, npx create-gsap-starter-kit scaffolds it, and if you want a site built this way from the start, see how I handle web development.
Frequently asked questions
Do I still need gsap.context() with useGSAP?
No. The useGSAP hook creates a gsap.context() for you and reverts it when the component unmounts. Everything you create inside the hook callback is recorded, including tweens, timelines and ScrollTriggers. You only need contextSafe for animations created later, such as in a click handler, so that they are recorded too.
Should I use useEffect or useGSAP in Next.js?
Use useGSAP. It runs like useLayoutEffect in the browser and falls back to useEffect on the server, so you avoid the React warning. It also handles cleanup and React strict mode, where effects run twice in development. With a plain useEffect you must create and revert a gsap.context() yourself every time.
Can I use GSAP in a Server Component?
No. GSAP needs the DOM, and Server Components render on the server with no DOM. Keep the page a Server Component and move the animated part into a small client component with "use client" at the top. You can pass server data to it as props, so you keep server rendering for content and SEO.
Why do my ScrollTrigger start and end positions look wrong?
ScrollTrigger measures the page when it is created. If images, fonts or async content change the height after that, the stored positions are wrong. Give images a fixed width and height, call ScrollTrigger.refresh() after document.fonts.ready, and refresh after a route change. Turn on markers: true while you debug to see the real positions.
