Measurement notes

    SSR on Next.js App Router for SEO — what to render where, with measurements

    Six SaaS sites migrated from Pages Router to App Router in the last year. The four rendering modes, what each one does to TTFB, LCP and indexability, and the decision rules we ship by default per page type.

    May 17, 202621 min readBy Ritesh + Soumodip
    Next.js App Router rendering modes and SEO impact measurements

    What we measured, on what

    Six funded-SaaS sites we migrated from Pages Router to App Router across the last 12 months: a developer tools homepage, a B2B compliance dashboard's marketing surface, a fintech pricing page, a healthtech blog, an ecommerce product index, and a media publisher's article template. All six were already on Next.js 13 or 14 Pages Router; the migrations took 2-5 weeks each.

    For each page, we measured three signals before and after the migration:

    • TTFB — CrUX p75, measured in the calendar month after migration.
    • LCP — same source, same window.
    • Indexability — the percentage of links visible in the rendered HTML that Googlebot can also see with JS disabled (our standard RDI metric from the JavaScript SEO study).

    The same measurement loop runs against every site on our technical SEO for SaaS retainer — both before-and-after on rendering changes and as a monthly baseline that catches accidental regressions.

    The four rendering modes, decoded

    The App Router does not call them “modes” in the docs, but functionally these are the four distinct shapes the framework will take on a given route. Knowing which one your code triggered is the entire game.

    ModeWhat triggers itHTML at request timeBest for
    Static (SSG)Server Component with no dynamic data; or
    export const dynamic = "force-static"
    Fully rendered, cached at the CDN edgeMarketing, blog, docs
    SSR (dynamic)Server Component that reads cookies() or headers(), or that fetches with cache: "no-store"Fully rendered, per-requestPersonalised pages, dashboards behind auth
    Streaming SSRServer Component with <Suspense> wrapping async childrenShell renders fast; chunks stream inSlow-data pages where the shell can render without the data
    Client Component"use client" directive at the top of the fileEmpty mount point; React hydrates client-sideInteractive widgets (forms, charts, drag-drop)

    What each mode does for SEO

    Below: the measurements across the six sites we migrated. Numbers are median deltas at the page-type level. Higher indexability = better; lower TTFB and LCP = better.

    Page typeMode shippedTTFB beforeTTFB afterLCP afterRDI after
    Marketing homepageStatic (SSG)410 ms95 ms1.4 s100%
    Pricing page (A/B)SSR (dynamic, cookie-based variant)580 ms240 ms1.9 s98%
    Product index (100s of items)Streaming SSR1.2 s180 ms2.3 s96%
    Blog articleStatic (SSG)340 ms85 ms1.2 s100%
    Dashboard (auth-gated)SSR shell + Client Components inside820 ms310 ms2.6 sn/a (noindex)
    Search resultsStreaming SSR with Suspense per facet1.8 s210 ms2.1 s92%

    Sources: CrUX field data, post-migration calendar months; RDI from our rendered-vs-raw HTML crawler. TTFB “before” figures are Pages Router with the same data sources.

    Two patterns to call out. First, the static page TTFB improvements are huge because the App Router default serves cached static HTML from the CDN edge, where Pages Router on Vercel was rendering on-demand at the function edge with a cold start. Second, the dashboard's LCP gets worse than the marketing pages because the Client Components inside need to hydrate — but the dashboard is behind auth and is not indexed, so LCP-for-SEO does not apply.

    Recipes per page type

    Recipe 1 — Marketing pages

    Goal. Maximum indexability, lowest TTFB, shareable URLs. Shape. Pure Server Component, no dynamic data, no cookies() or headers(), no fetches with cache: "no-store". The route gets statically rendered at build and serves from the CDN. This is the default shape on every SaaS marketing surface we build through our SaaS web-app development engagement.

    app/page.tsx — homepage as a Server Component
    typescript
    No 'use client' anywhere in this tree. The Stripe/HubSpot/Plausible scripts go via the next/script component with the lazy strategy so they don't block the critical path.
    import { Hero } from "@/components/marketing/Hero";
    import { Features } from "@/components/marketing/Features";
    import { Testimonials } from "@/components/marketing/Testimonials";
    import { CallToAction } from "@/components/marketing/CallToAction";
    
    // Static by default — no dynamic APIs used anywhere below
    export default async function HomePage() {
      // fetch here would also default to caching unless explicit no-store
      const testimonials = await getTestimonials();
      return (
        <>
          <Hero />
          <Features />
          <Testimonials items={testimonials} />
          <CallToAction />
        </>
      );
    }
    
    export const metadata = {
      title: "Acme — the simple SaaS for X",
      description: "Acme makes X simple. Used by 4,000 teams.",
      openGraph: { /* ... */ },
    };

    Recipe 2 — Pricing with A/B

    Goal. Indexable and personalised. Shape. Server Component that reads a variant cookie, picks a variant, renders. The page is dynamic (per-request) but the response time stays under 250ms because the work is just a cookie read + a hash.

    app/pricing/page.tsx
    typescript
    Reading cookies opts you out of static rendering, but the page is still rendered server-side and fully visible to crawlers. Googlebot doesn't carry your A/B cookie, so it always gets the control variant — which is also what you want for canonical URL parity.
    import { cookies } from "next/headers";
    import { PricingTable } from "@/components/pricing/PricingTable";
    import { getActiveVariants } from "@/lib/ab";
    
    export default async function PricingPage() {
      const variants = await getActiveVariants();
      const c = cookies();
      const variant = c.get("ab.pricing")?.value ?? "control";
      const config = variants[variant] ?? variants.control;
    
      return <PricingTable config={config} />;
    }
    
    export const metadata = {
      title: "Pricing — Acme",
      description: "Plans for teams of any size. Free trial, no card required.",
      alternates: { canonical: "https://acme.com/pricing/" },
    };

    Recipe 3 — Product index (streaming)

    Goal. Shell renders fast (good for LCP); product cards stream in. Each card is still in the HTML for crawlers because Next sends the full chunked response.

    app/products/page.tsx
    typescript
    import { Suspense } from "react";
    import { ProductGridSkeleton } from "@/components/products/Skeleton";
    import { ProductGrid } from "@/components/products/Grid";
    import { PageHeader } from "@/components/products/Header";
    
    export default function ProductsPage() {
      return (
        <>
          <PageHeader />
          <Suspense fallback={<ProductGridSkeleton />}>
            {/* This server component awaits a slow data source. The
                outer page TTFB is fast because the shell is rendered
                without waiting for it. */}
            <ProductGrid />
          </Suspense>
        </>
      );
    }

    The key SEO win here: crawlers receive the full streamed response, so the rendered HTML still contains every product card link. Googlebot does not stop reading when the first chunk arrives.

    Recipe 4 — Dashboard (Client Components inside SSR shell)

    Goal. No indexability needed (auth-gated); fast hydration; interactive widgets. Shape. Server-rendered shell with auth check; Client Components for the interactive bits inside.

    app/dashboard/page.tsx — server shell
    typescript
    import { redirect } from "next/navigation";
    import { getSession } from "@/lib/auth";
    import { DashboardChart } from "@/components/dashboard/Chart"; // 'use client'
    import { DashboardTable } from "@/components/dashboard/Table"; // 'use client'
    
    export const metadata = { robots: { index: false } };
    
    export default async function DashboardPage() {
      const session = await getSession();
      if (!session) redirect("/login");
    
      const data = await getDashboardData(session.userId);
    
      return (
        <main>
          <h1>Welcome back, {session.user.name}</h1>
          <DashboardChart series={data.series} />
          <DashboardTable rows={data.rows} />
        </main>
      );
    }

    The rules of thumb that hold up

    Across the six migrations, the rules that consistently produced the best outcome:

    1. Default to Server. Add "use client" only where you need state, effects, or browser-only APIs. Most of an app does not.
    2. Don't opt out of caching without a reason. Every cache: "no-store" and every cookies() read collapses static rendering to dynamic. Audit them.
    3. Use Suspense streaming for slow data only. If the data is fast, streaming adds complexity for no win.
    4. Keep client islands small. A Client Component should be the leaf, not the page. Hydration cost scales with the size of the client bundle.
    5. Add JSON-LD on the server, not the client. The mistake we see most often when auditing JS SEO — covered in the funded SaaS JS SEO study and the schema A/B test.
    6. Set metadata per route. The new metadata API is the cleanest place to put title, description, canonical, OG tags. Do it once per route.

    Streaming SSR caveats

    Streaming with Suspense is genuinely powerful for slow data sources, but it has two pitfalls we hit on every migration:

    • Search engine bots may close the connection early. Most do not, but the very long-tail crawlers (some baidu / yandex variants) sometimes cut after the first chunk. Keep the most important content in the shell, not behind Suspense.
    • Streaming breaks if a parent server component throws. Anything inside Suspense needs a sibling error.tsx at that level, or the whole stream blanks.

    The Pages Router → App Router migration itself usually runs as a 3-5 week engagement on a typical SaaS marketing surface, depending on how many Client Components need to be split out from existing pages. We run this specifically through our tech-stack migration engagement so the SEO measurement loop above runs through the full migration window, not just at the end.

    ■ Related services

    Three engagements that ship this work

    The technical-SEO engagement that audits and rewrites the rendering layer, the SaaS build that ships App Router by default, and the migration engagement that runs Pages Router -> App Router as a project:

    Frequently asked questions

    When should I use a Server Component vs a Client Component in Next.js App Router?
    Default to Server. Add `'use client'` only where you need state, effects, or browser-only APIs. Most of an app does not. Keep Client Components small and at the leaf level — hydration cost scales with the client-bundle size.
    Is streaming SSR with Suspense good for SEO?
    Yes — Googlebot receives the full streamed response and the rendered HTML still contains everything that was inside Suspense. The caveat is that some long-tail crawlers cut after the first chunk, so keep critical content (canonical, schema, primary copy) in the shell, not behind Suspense.
    What happens to indexability if I use cookies() or headers() in a Next.js page?
    The page collapses from static to dynamic rendering, but it stays server-rendered and fully visible to crawlers. Googlebot does not carry your auth or A/B cookie, so it always gets the control variant — which is also what you want for canonical URL parity.
    Ritesh — Founding Partner, Appycodes

    About the author

    RiteshFounding Partner, Appycodes

    LinkedIn

    Co-authored with Soumodip Mukherjee, Technical SEO Lead

    Ritesh runs engineering at Appycodes. Soumodip leads the technical-SEO practice and ran the before/after measurements behind this post — six migrations across funded SaaS clients in the last 12 months, with TTFB / LCP / indexability tracked through the same monitoring stack we use on every SEO retainer.

    Last reviewed: May 17, 2026

    Full stack web and mobile tech company

    Taking the first step is the hardest. We make everything after that simple.

    Let's talk today