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
RDImetric 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.
| Mode | What triggers it | HTML at request time | Best for |
|---|---|---|---|
| Static (SSG) | Server Component with no dynamic data; orexport const dynamic = "force-static" | Fully rendered, cached at the CDN edge | Marketing, blog, docs |
| SSR (dynamic) | Server Component that reads cookies() or headers(), or that fetches with cache: "no-store" | Fully rendered, per-request | Personalised pages, dashboards behind auth |
| Streaming SSR | Server Component with <Suspense> wrapping async children | Shell renders fast; chunks stream in | Slow-data pages where the shell can render without the data |
| Client Component | "use client" directive at the top of the file | Empty mount point; React hydrates client-side | Interactive 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 type | Mode shipped | TTFB before | TTFB after | LCP after | RDI after |
|---|---|---|---|---|---|
| Marketing homepage | Static (SSG) | 410 ms | 95 ms | 1.4 s | 100% |
| Pricing page (A/B) | SSR (dynamic, cookie-based variant) | 580 ms | 240 ms | 1.9 s | 98% |
| Product index (100s of items) | Streaming SSR | 1.2 s | 180 ms | 2.3 s | 96% |
| Blog article | Static (SSG) | 340 ms | 85 ms | 1.2 s | 100% |
| Dashboard (auth-gated) | SSR shell + Client Components inside | 820 ms | 310 ms | 2.6 s | n/a (noindex) |
| Search results | Streaming SSR with Suspense per facet | 1.8 s | 210 ms | 2.1 s | 92% |
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.
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.
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.
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.
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:
- Default to Server. Add
"use client"only where you need state, effects, or browser-only APIs. Most of an app does not. - Don't opt out of caching without a reason. Every
cache: "no-store"and everycookies()read collapses static rendering to dynamic. Audit them. - Use Suspense streaming for slow data only. If the data is fast, streaming adds complexity for no win.
- Keep client islands small. A Client Component should be the leaf, not the page. Hydration cost scales with the size of the client bundle.
- 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.
- Set
metadataper 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.tsxat 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 research
Related research
Three companion SEO studies from the same crawler / measurement set:
■ 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.

About the author
Ritesh — Founding Partner, Appycodes
LinkedInCo-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.
