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.
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:
| Route | Router | What it renders |
|---|---|---|
/app-router | App Router (Server Component + Metadata API) | An article: H1, six paragraphs, FAQ, links |
/pages-router | Pages Router (next/head) | The same article component |
/app-router-md | App Router | The same article written in Markdown, converted with marked during render |
/pages-router-md | Pages Router | The 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
| Route | HTML (KB) | Words in raw HTML | Title + meta + canonical in HTML | JS files | JS transferred (KB) | FCP | LCP | TBT |
|---|---|---|---|---|---|---|---|---|
| App Router | 13.0 | 544 | yes | 5 | 132.3 | 242 ms | 242 ms | 134 ms |
| Pages Router | 5.0 | 544 | yes | 8 | 114.6 | 260 ms | 260 ms | 90 ms |
App Router + marked | 12.6 | 544 | yes | 5 | 132.3 | 227 ms | 227 ms | 92 ms |
Pages Router + marked | 5.0 | 544 | yes | 9 | 125.5 | 216 ms | 216 ms | 100 ms |
What the numbers say
- 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.
- 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.
- Server-only dependencies are free in the App Router. Adding
markeddidn'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. - 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
// 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 /></>)
}"""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
| Feature | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| Default component type | Client components that hydrate | Server Components (no client JS unless "use client") |
| Data fetching | getStaticProps, getServerSideProps | async components and fetch with caching |
| Head tags | next/head in each page | metadata export or generateMetadata |
| Sitemaps and robots | Static files or a custom route | app/sitemap.ts and app/robots.ts |
| Streaming | Whole page renders before sending | Suspense boundaries stream progressively |
| Not-found handling | pages/404.js | notFound() 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
// 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
"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).- No
metadataBase. Relative canonical and Open Graph URLs needmetadataBase: new URL('https://example.com')in the root layout to resolve to absolute URLs. - Losing the sitemap. If your sitemap was generated by a Pages Router route or a build script, recreate it as
app/sitemap.tsbefore deleting the old one. - Changing URLs during the migration. Keep paths, trailing-slash behaviour and canonicals identical, or add redirects.
- Benchmarking in dev mode.
next devis 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.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.