WorkLabsWritingAboutServicesPlaygroundContact
Available · 2026
abhirupdattak6@gmail.com
Writing
EngineeringBy Abhirup Datta Khan15 June 202610 min read

Building a SaaS landing page with Next.js and GSAP

Build a SaaS landing page with Next.js and GSAP that still scores on Core Web Vitals: App Router, ScrollTrigger pinning, the pin-spacer crash, and reduced motion.

The brief most people get wrong

A SaaS landing page has two jobs that pull against each other. It has to convert, which usually means motion, depth, and a hero that feels alive. It also has to load fast and rank, which usually means shipping as little JavaScript as possible. Most builds pick a side. The marketing team gets their animations and the page weighs 800KB of hydration. Or the engineering team wins and you get a static brochure that converts like one.

I build these for clients as a solo designer-developer, so I sit on both sides of that argument by default. There is no handoff where the polish gets lost. Over a few of these (you can see two of them, Viswa and DocPulse, on my work page), I have landed on a structure with Next.js 16 and GSAP that keeps both jobs honest. This is that structure, including the bug that nearly shipped to production and taught me where the real trap is.

Why the App Router earns its place here

The App Router defaults to React Server Components. For a landing page that detail matters more than it does almost anywhere else, because a landing page is mostly content. Headings, copy, feature grids, testimonials, pricing. None of that needs to run on the client.

When a component is a Server Component, its JavaScript never reaches the browser. You render the HTML on the server, send it down, and the client pays nothing to hydrate it. So your hero copy, your feature list, your footer, all of it is in the initial document as real text. Good for the largest contentful paint, good for crawlers, good for the user on a mid-range Android phone in patchy coverage.

The other thing you get for free is the Metadata API. No next/head, no third-party SEO component. You export a metadata object (or generateMetadata if it is dynamic) from the page and Next handles the tags.

// app/page.tsx  (Server Component by default)
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "DocPulse: clinical docs that keep up",
  description:
    "DocPulse turns messy clinical notes into structured records in seconds.",
  openGraph: {
    title: "DocPulse",
    description: "Structured clinical records in seconds.",
    images: ["/og/docpulse.png"],
  },
  alternates: { canonical: "https://example.com" },
};

export default function Page() {
  return (
    <main>
      <Hero />
      <Features />
      <Pricing />
    </main>
  );
}

The rule I follow: content is a Server Component until it has a concrete reason not to be. Animation is a reason. State is a reason. "It might be nice later" is not.

The island pattern: keep the client small

GSAP needs the DOM. It reads layout, measures elements, attaches scroll listeners. That is client work, so any component running GSAP has to be a Client Component with "use client" at the top. The mistake is letting that boundary creep upward until your whole page is a client tree.

Don't. Keep the page and its content on the server, and drop a thin client island exactly where the motion lives.

// app/page.tsx - still a Server Component
import RevealOnScroll from "@/components/RevealOnScroll";

export default function Page() {
  return (
    <main>
      <Hero />
      {/* The text is server-rendered. The island only wires up the motion. */}
      <RevealOnScroll>
        <Features />
      </RevealOnScroll>
    </main>
  );
}

The content inside the island is still passed as children, so it is still server-rendered HTML sitting in the document. The client component animates nodes that already exist. It does not generate them. This is the difference between a page that reveals content and a page that is blank until JavaScript decides otherwise. Search engines and screen readers see the first one. Always build the first one.

Wiring GSAP and ScrollTrigger the right way

Two non-negotiables before any tween. Register the plugin on the client only, and scope every animation so it cleans itself up. gsap.context() exists for exactly this: it records every tween and ScrollTrigger you create inside it, and revert() kills all of them in one call.

"use client";

import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function RevealOnScroll({
  children,
}: {
  children: React.ReactNode;
}) {
  const root = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      gsap.from(".reveal-item", {
        y: 24,
        autoAlpha: 0,
        duration: 0.6,
        stagger: 0.08,
        scrollTrigger: {
          trigger: root.current,
          start: "top 80%",
        },
      });
    },
    { scope: root }
  );

  return <div ref={root}>{children}</div>;
}

useGSAP is the official React hook. It runs inside a gsap.context() scoped to root, and on unmount it reverts that context for you. Scoping to root also means the .reveal-item selector only matches inside this island, not across the whole document. If you are not on the hook yet, the manual version is a useLayoutEffect that returns () => ctx.revert(). Note the effect choice. It matters, and the next section is why.

The bug that taught me where the real trap is

Here is the one that cost me an evening, and it is the reason I am writing this with any authority at all.

ScrollTrigger with pin: true does something invasive. To hold an element in place while the page scrolls, it wraps that element in a generated div called a pin-spacer and moves your element inside it. That pin-spacer is created by GSAP, outside React. React's virtual DOM has no idea it exists. As far as React is concerned, your pinned section is still a direct child of wherever you put it in the JSX.

Now the user navigates away. React starts unmounting the page and calls removeChild on your section, expecting it under its original parent. But GSAP moved it inside the pin-spacer. The node React is looking for is not where React thinks it is.

Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.

The page crashes on navigation. Not on load, not in dev usually, only when someone leaves a route that has a pinned ScrollTrigger. That is the worst kind of bug: invisible in the obvious tests, reliable for real users.

The cause is effect timing. If your GSAP teardown lives in a plain useEffect cleanup, it runs passively, after React has already committed DOM mutations. By the time ctx.revert() unwinds the pin-spacer, React has already tried (and failed) to remove the node. You have to unwind the pin-spacer before React touches the DOM.

The fix is useLayoutEffect. Layout effects and their cleanups fire synchronously during the commit phase, before the browser paints and before passive effects run. Revert there and the pin-spacer is gone before React starts deleting nodes, so React finds everything exactly where it expects.

"use client";

import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger, useGSAP);

export default function PinnedSection({
  children,
}: {
  children: React.ReactNode;
}) {
  const root = useRef<HTMLDivElement>(null);

  // useGSAP runs in a layout effect by default and reverts the
  // context (pin-spacer included) synchronously on unmount.
  useGSAP(
    () => {
      gsap.to(".panel", {
        xPercent: -100,
        ease: "none",
        scrollTrigger: {
          trigger: root.current,
          pin: true,
          scrub: 1,
          end: "+=1200",
        },
      });
    },
    { scope: root }
  );

  return (
    <div ref={root}>
      <div className="panel">{children}</div>
    </div>
  );
}

If you are writing the effect by hand instead of using the hook, the shape that matters is this:

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    /* tweens with pin: true */
  }, root);
  return () => ctx.revert(); // synchronous, before React removes the DOM
}, []);

The lesson I keep coming back to: when a library re-parents DOM nodes outside React's tree, React's reconciler and that library are now sharing custody of the DOM. Layout-effect teardown is the only handoff that happens in the right order. A passive useEffect cleanup is a race you will lose on a real user's navigation.

For a while I was convinced this was a browser extension mangling the DOM, because the error looked exactly like the classic extension-vs-React removeChild crash. It was not. A clean profile with zero extensions reproduced it every time. The takeaway there is its own lesson: do not blame the extension until a guest profile says otherwise.

Reduced motion is not optional

Some people get motion sickness from scroll-jacking and parallax. Some are on a laptop where every scrubbed tween is a dropped frame. Both deserve a page that works. gsap.matchMedia makes this clean because it ties an animation set to a media query and reverts it automatically when the query stops matching.

useGSAP(
  () => {
    const mm = gsap.matchMedia();

    mm.add("(prefers-reduced-motion: no-preference)", () => {
      // Full scroll choreography only for people who want it.
      gsap.from(".reveal-item", {
        y: 24,
        autoAlpha: 0,
        stagger: 0.08,
        scrollTrigger: { trigger: root.current, start: "top 80%" },
      });
    });

    mm.add("(prefers-reduced-motion: reduce)", () => {
      // Static fallback: everything visible, no movement.
      gsap.set(".reveal-item", { autoAlpha: 1, y: 0 });
    });
  },
  { scope: root }
);

The detail that catches people: if your reveal animation uses autoAlpha: 0 as a from state and you skip the reduced-motion branch, the content stays invisible for those users. The animation that was supposed to bring it in never runs, so it never arrives. Always ship a branch that just makes the content visible. Reduced motion means less movement, not less content.

Keep the heavy stuff off the critical path

If your hero has a Three.js scene or a WebGL shader, that is the single biggest threat to your load time. WebGL pulls a large library and starts a render loop, and none of it should block your largest contentful paint. The text and the call-to-action are what convert. The shader is garnish.

Lazy-load it with next/dynamic and ssr: false. There is no point server-rendering a canvas that needs a GPU, and skipping SSR keeps it out of the initial HTML.

"use client";

import dynamic from "next/dynamic";

const HeroCanvas = dynamic(() => import("./HeroCanvas"), {
  ssr: false,
  loading: () => <div className="hero-bg" aria-hidden />,
});

export function HeroVisual() {
  return <HeroCanvas />;
}

The loading fallback is a plain styled div, so the hero has a background instantly while the real scene streams in behind your already-painted text. Use Three.js sparingly. One memorable visual beats five that each cost you 200ms.

Smooth scroll with Lenis is the same idea: a nice-to-have that must never become a tax. Initialize it on the client, keep its requestAnimationFrame loop lean, and gate it behind the same reduced-motion check. If someone asked the browser for less motion, do not hijack their scroll.

"use client";

import { useEffect } from "react";
import Lenis from "lenis";

export function useSmoothScroll() {
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const lenis = new Lenis();
    let id: number;
    const raf = (time: number) => {
      lenis.raf(time);
      id = requestAnimationFrame(raf);
    };
    id = requestAnimationFrame(raf);

    return () => {
      cancelAnimationFrame(id);
      lenis.destroy();
    };
  }, []);
}

If you run ScrollTrigger and Lenis together, remember to tell ScrollTrigger to update on Lenis's scroll events (lenis.on("scroll", ScrollTrigger.update)), or your triggers will fire against the native scroll position and drift.

The discipline that holds it together

None of this is exotic. It is a handful of rules applied without exceptions.

Content is server-rendered and lives in the DOM as real text. Client islands are small and only animate nodes that already exist. GSAP is scoped and reverted, and anything that pins is torn down in a layout effect so the pin-spacer unwinds before React unmounts. Reduced motion gets a real static fallback, not a blank screen. The heavy visual is lazy and off the critical path.

Do that and you stop having to choose between a page that moves and a page that loads. You get both, which on a SaaS landing page is the entire point. The motion earns attention and the speed keeps it.

If you want a landing page built this way, design and code from the same person with no handoff in between, that is exactly what I do at my services page.