Next.js App Router vs Pages Router: SEO Benchmarks (2026)

We built the same page in the Next.js 16 App Router and Pages Router and measured HTML, JavaScript, FCP and TBT. Real numbers, plus when migrating helps SEO.

BugViso

12 min read

Quick answer: For SEO, both Next.js routers can produce identical results: when pages are statically generated or server-rendered, crawlers get the full content, title, meta description and canonical in the HTML either way. In our Next.js 16 benchmark, the App Router shipped more JavaScript on a simple page (132 KB vs 115 KB) but none of a page's server-only dependencies, while the Pages Router shipped them all. Migrate for maintainability and for pages with heavy render-only libraries, not for an automatic SEO boost.

There's a lot of confident advice about the App Router "beating" the Pages Router for SEO. We couldn't find a controlled comparison, so we built one: the same article page in both routers, in the same Next.js 16.3.8 project, measured the same way.


The Benchmark

Setup. One Next.js 16.3.8 project (Turbopack build, React 19.2.7) with four statically generated routes:

RouteRouterWhat it renders
/app-routerApp Router (Server Component + Metadata API)An article: H1, six paragraphs, FAQ, links
/pages-routerPages Router (next/head)The same article component
/app-router-mdApp RouterThe same article written in Markdown, converted with marked during render
/pages-router-mdPages RouterThe same Markdown article

Measurement. Raw HTML fetched with a plain HTTP request. Browser metrics are medians of seven loads in headless Chromium with Lighthouse-style mobile throttling (4× CPU, 150 ms RTT, 1.6 Mbps) and the cache disabled. Measured 8 October 2026.

Results

RouteHTML (KB)Words in raw HTMLTitle + meta + canonical in HTMLJS filesJS transferred (KB)FCPLCPTBT
App Router13.0544yes5132.3242 ms242 ms134 ms
Pages Router5.0544yes8114.6260 ms260 ms90 ms
App Router + marked12.6544yes5132.3227 ms227 ms92 ms
Pages Router + marked5.0544yes9125.5216 ms216 ms100 ms

What the numbers say

  1. Crawlers see the same thing. All four routes put all 544 words and every head tag in the raw HTML. For indexing, the router doesn't matter when pages are prerendered or server-rendered.
  2. The App Router has a bigger fixed cost. Its HTML is 2.5× larger, because it inlines the React Server Components payload, and its baseline JavaScript was about 18 KB larger in our build.
  3. Server-only dependencies are free in the App Router. Adding marked didn't change the App Router's JavaScript at all (132.3 KB both times); the Pages Router grew by about 11 KB because the page component, and everything it imports, hydrates in the browser.
  4. Paint times were a wash. FCP and LCP differed by less than run-to-run noise on these small pages. TBT varied between runs (90–134 ms) more than between routers.

So the crossover is about dependencies, not routers. If your pages import parsers, formatters, syntax highlighters or CMS SDKs just to render, the App Router keeps them off the client and can come out ahead. If your pages are simple, the Pages Router can ship less.

Reproduce it

javascript
// lib/ArticleMd.js: the component both routers render
import { marked } from 'marked'
import { article } from './content'
const md = `# ${article.title}\n\n` + article.paragraphs.join('\n\n')
export default function ArticleMd() {
  return <main dangerouslySetInnerHTML={{ __html: marked.parse(md) }} />
}

// app/app-router-md/page.js: Server Component, `marked` stays on the server
export const metadata = { title: article.title, description: article.description }
export default function Page() { return <ArticleMd /> }

// pages/pages-router-md.js: hydrated page, `marked` ships to the browser
export default function Page() {
  return (<><Head><title>{article.title}</title></Head><ArticleMd /></>)
}
python
"""Measure FCP, LCP, TBT and JS bytes for a URL (median of N throttled mobile loads).

Usage: pip install playwright && python -m playwright install chromium
       python measure.py http://localhost:3000/app-router [runs]
"""
import statistics, sys
from playwright.sync_api import sync_playwright

INIT = """window.__m = {lt: []};
new PerformanceObserver(l => { for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') __m.fcp = e.startTime; }).observe({type: 'paint', buffered: true});
new PerformanceObserver(l => { for (const e of l.getEntries()) __m.lcp = e.startTime; }).observe({type: 'largest-contentful-paint', buffered: true});
new PerformanceObserver(l => { for (const e of l.getEntries()) __m.lt.push([e.startTime, e.duration]); }).observe({type: 'longtask', buffered: true});"""
COLLECT = """() => { let tbt = 0; for (const [s, d] of __m.lt) if (s + d > (__m.fcp || 0) && d > 50) tbt += d - 50;
  const js = performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script' || r.name.endsWith('.js'));
  return {fcp: __m.fcp, lcp: __m.lcp, tbt, jsKB: js.reduce((a, r) => a + r.transferSize, 0) / 1024}; }"""

def measure(url, runs=7):
    results = []
    with sync_playwright() as p:
        browser = p.chromium.launch()
        for _ in range(runs):
            ctx = browser.new_context(viewport={"width": 412, "height": 823}, is_mobile=True)
            ctx.add_init_script(INIT)
            page = ctx.new_page()
            cdp = ctx.new_cdp_session(page)
            cdp.send("Network.enable")
            cdp.send("Network.setCacheDisabled", {"cacheDisabled": True})
            cdp.send("Network.emulateNetworkConditions", {"offline": False, "latency": 150,
                     "downloadThroughput": 1.6 * 1024 * 1024 / 8, "uploadThroughput": 750 * 1024 / 8})
            cdp.send("Emulation.setCPUThrottlingRate", {"rate": 4})
            page.goto(url, wait_until="networkidle")
            page.wait_for_timeout(1000)
            results.append(page.evaluate(COLLECT))
            ctx.close()
        browser.close()
    return {k: statistics.median(r[k] for r in results) for k in ("fcp", "lcp", "tbt", "jsKB")}

if __name__ == "__main__":
    m = measure(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 7)
    print(f"FCP {m['fcp']:.0f} ms  LCP {m['lcp']:.0f} ms  TBT {m['tbt']:.0f} ms  JS {m['jsKB']:.1f} KB")

Run it against your own routes before and after a migration, on a production build (next build && next start), never on next dev.


SEO Features: Pages Router vs App Router

FeaturePages Router (pages/)App Router (app/)
Default component typeClient components that hydrateServer Components (no client JS unless "use client")
Data fetchinggetStaticProps, getServerSidePropsasync components and fetch with caching
Head tagsnext/head in each pagemetadata export or generateMetadata
Sitemaps and robotsStatic files or a custom routeapp/sitemap.ts and app/robots.ts
StreamingWhole page renders before sendingSuspense boundaries stream progressively
Not-found handlingpages/404.jsnotFound() and not-found.js

Both routers can coexist in one project, which is how our benchmark ran, so you can migrate route by route.


Migrating a Route: getStaticProps to a Server Component

tsx
// app/blog/[slug]/page.tsx (App Router)
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

type Props = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return {}
  return { title: post.title, description: post.excerpt, alternates: { canonical: `/blog/${slug}` } }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return (<article><h1>{post.title}</h1><div dangerouslySetInnerHTML={{ __html: post.html }} /></article>)
}

Since Next.js 15, params and searchParams are Promises: forgetting to await them breaks dynamic routes.


Migration Pitfalls That Hurt SEO

  1. "use client" too high in the tree. Marking a layout or page as a client component brings its imports back into the browser bundle, which erases the App Router's main advantage. Keep client components at the leaves (buttons, forms, widgets).
  2. No metadataBase. Relative canonical and Open Graph URLs need metadataBase: new URL('https://example.com') in the root layout to resolve to absolute URLs.
  3. Losing the sitemap. If your sitemap was generated by a Pages Router route or a build script, recreate it as app/sitemap.ts before deleting the old one.
  4. Changing URLs during the migration. Keep paths, trailing-slash behaviour and canonicals identical, or add redirects.
  5. Benchmarking in dev mode. next dev is unoptimised; compare production builds only.

Our Next.js 15 SEO guide covers the Metadata API in depth, and React error #418 covers hydration mismatches that often surface during a migration.


How BugViso Audits Next.js Sites

A BugViso audit renders each page in headless Chromium and reports the things that change during a router migration: JavaScript and CSS coverage per file (with files over 40% unused flagged), long tasks with the worst script named, hydration errors (#418, #423, #425) from the console, raw HTML vs rendered DOM parity, and head tags, canonicals and structured data per page. Run it before and after migrating a section to compare the two runs in your audit records.

More detail is on the website audit tool for developers feature page.


FAQ

Is the App Router better for SEO than the Pages Router?

Not inherently. Both produce fully rendered HTML with head tags when pages are static or server-rendered. The App Router can ship less JavaScript on pages with heavy server-only dependencies; on simple pages it shipped more in our test.

Should I migrate from Pages Router to App Router for SEO?

Migrate for maintainability, streaming and server-only data fetching. If your goal is purely SEO, fix rendering, metadata and Core Web Vitals in your current router first.

Can the Pages Router and App Router run in the same project?

Yes. Our benchmark used both in one project. Route conflicts aside, you can migrate one section at a time.

Why is App Router HTML larger?

It inlines the React Server Components payload that the client uses to reconcile the page. That's why HTML size grows even when JavaScript doesn't.

Does Google index App Router pages differently?

No. Googlebot indexes the HTML and rendered DOM the same way regardless of router.


The Takeaway

The router you pick changes your bundle, not your crawlability, so measure your own pages before and after migrating and confirm the result with a BugViso rendered audit.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.