32blogby Studio Mitsu

How I Cut Next.js App Router TTFB from 1s to 100ms

32blog's TTFB dropped from 1260ms to 100ms after removing one dynamic API call from root layout. How headers() silently forces dynamic rendering across every route in Next.js App Router — and the canonical fix.

by omitsu10 min read
On this page

If your Next.js App Router site feels sluggish (TTFB around 1 second, x-vercel-cache: MISS on every request), check whether your root app/layout.tsx calls headers(), cookies(), or getLocale() from next-intl/server. These dynamic APIs in the root layout quietly force every route in the app into dynamic rendering — generateStaticParams and dynamicParams = false get silently ignored. I shipped this exact regression on 32blog.com and didn't notice for two weeks. Here's how I diagnosed it, the one-file fix, and the verification commands that would have caught it on day one.

The symptom: TTFB stuck at 1.26s for two weeks

I migrated 32blog.com from WordPress to Next.js specifically because I wanted the performance reputation that comes with the framework. Post-migration, pages still felt slow on click. I told myself "it's App Router, that's just how it is" and shipped.

It wasn't.

Here's what I measured on my article pages before the fix:

bash
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\n" https://32blog.com/en/cli/cli-prompt-starship
# TTFB: 1.257s

Response headers told the rest of the story:

cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
x-vercel-id: hnd1::iad1::vxsv9-...

Three red flags:

  1. no-store — Vercel's edge CDN was explicitly told not to cache
  2. x-vercel-cache: MISS — confirming the CDN was being bypassed on every request
  3. hnd1::iad1 — edge PoP in Tokyo, but the function itself executed in iad1 (US East Virginia), adding ~150-180ms of transatlantic round-trip per request

The .next/server/app/ directory did contain prerendered HTML files for every article. They were just being ignored at runtime.

The culprit: headers() in root layout

I ran npm run build and looked at the route classification output:

├ ƒ /[locale]                    ← Dynamic
├ ƒ /[locale]/[category]         ← Dynamic
├ ƒ /[locale]/[category]/[slug]  ← Dynamic (!)

ƒ means "server-rendered on demand." But my article pages explicitly declared export const dynamicParams = false and generateStaticParams returning all 440 articles × 3 locales. They should have been (prerendered static HTML). Something was overriding.

The offending commit, from two weeks earlier:

tsx
// app/layout.tsx — commit a126dc7 "SEO fix: dynamic html lang"
import { headers } from "next/headers";

export default async function RootLayout({ children }) {
  const headersList = await headers();          // ← this one line
  const lang = headersList.get("x-locale") ?? "en";
  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  );
}

The intent was legitimate: <html lang="en"> was hard-coded, which meant /ja/... pages were served with the wrong language attribute. Google treats lang as a ranking signal for locale targeting, so making it dynamic was correct SEO work.

The implementation was wrong in a way the framework didn't complain about.

Why Next.js goes dynamic on headers()

Next.js App Router draws a hard line: if a route's render tree calls a dynamic APIheaders(), cookies(), draftMode(), or reads searchParams — the entire route opts into dynamic rendering. This is a deliberate tradeoff: the framework can't safely prerender HTML at build time if the output depends on per-request data.

The consequences I missed at review time:

  • Root layout wraps every route. A dynamic API in app/layout.tsx cascades down. All 440 articles, all category pages, all tag pages — dynamic.
  • No error, no warning. generateStaticParams still runs at build. .next/server/app/**/*.html files still get written. They just don't get served at runtime.
  • dev mode always renders dynamically. npm run dev gave zero signal that production would behave differently.
  • Lighthouse scores stayed in the 80s. TTFB alone doesn't tank Core Web Vitals enough to alarm you.

The only visible symptom was subjective: clicks felt sluggish.

The canonical fix: move <html> into [locale]/layout.tsx

The correct pattern — documented in the official next-intl example — is to let the URL segment carry the locale, not an HTTP header.

Root layout becomes a passthrough:

tsx
// app/layout.tsx
import { ReactNode } from "react";

// Since we have a not-found.tsx on the root, a layout file is required,
// even if it's just passing children through.
export default function RootLayout({ children }: { children: ReactNode }) {
  return children;
}

Locale layout owns the <html> tag:

tsx
// app/[locale]/layout.tsx
import { setRequestLocale } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  // Enable static rendering — reads locale from URL params, not headers
  setRequestLocale(locale);

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

The key primitive is setRequestLocale. It registers the locale with next-intl for the current render without reading HTTP headers — which means the route stays eligible for static generation.

One more thing: when the root layout is a passthrough, it no longer provides <html> and <body> to sibling routes. app/not-found.tsx needs its own wrappers:

tsx
// app/not-found.tsx
export default function NotFound() {
  return (
    <html lang="en">
      <body>{/* 404 UI */}</body>
    </html>
  );
}

Verification: the three commands that should be in your deploy checklist

These are the checks I wish I'd run on every perf-adjacent PR.

1. Build output classification. Every content route should show (SSG), not ƒ (Dynamic):

bash
npm run build 2>&1 | grep -E "^(├|│|└)"

Look for the legend at the bottom: ● (SSG) prerendered as static HTML (uses generateStaticParams). If your article routes show ƒ, something in the render tree is forcing dynamic.

2. Production TTFB. After deploying, hit the same URL twice from a geographically relevant location:

bash
for i in 1 2 3; do
  curl -s -o /dev/null -w "Run$i TTFB: %{time_starttransfer}s\n" https://your-site.com/some-page
done

Run 1 may be ~500ms (CDN cold fill). Runs 2+ should be 100ms or under. If every run is 1s+, your CDN is being bypassed.

3. Cache headers. A statically-served page should show x-vercel-cache: HIT after warm-up:

bash
curl -sI https://your-site.com/some-page | grep -iE "cache-control|x-vercel-cache|x-vercel-id"

The anti-patterns to catch: cache-control containing no-store or private, x-vercel-cache: MISS on repeat requests, or x-vercel-id showing a non-edge function region.

Results after the fix

The changed files:

  • app/layout.tsx — reduced to return children
  • app/[locale]/layout.tsx — took ownership of <html>, <body>, fonts, analytics scripts
  • app/not-found.tsx — added its own <html>/<body> wrapper
  • export const dynamic = "force-static" added to 13 locale page files as belt-and-suspenders

Build output transformation. Before the fix, a single dynamic route line:

├ ƒ /[locale]/[category]/[slug]

After the fix, the same route expands into every prerendered path:

├ ● /[locale]/[category]/[slug]
│ ├ /ja/cli/cli-prompt-starship
│ ├ /en/cli/cli-prompt-starship
│ └ [+436 more paths]

Production TTFB, measured from Tokyo:

Page typeBeforeAfter (cold)After (warm)
Article1257ms590ms100ms
Homepage1234ms107ms96ms
Category950ms585ms90ms

x-vercel-id went from hnd1::iad1 (edge in Tokyo, function in US East) to hnd1 alone — no function invocation at all. The Vercel edge CDN just returns the prerendered HTML.

Other common paths to accidental dynamic rendering

The root-layout headers() trap is the most damaging, but it's not the only way to silently go dynamic. Watch for:

  • cookies() in shared layouts or middleware — same propagation, same silence
  • searchParams read at the page level — opts the page in, not the whole tree, but often the page is the highest-value route (homepage, article)
  • fetch(..., { cache: "no-store" }) — a single uncached fetch call in a layout or page is sufficient
  • Server Actions called during render — rare in content sites, but worth knowing
  • generateMetadata reading dynamic APIs — counts the same as the page reading them
  • Importing code that internally calls dynamic APIs — the most insidious. getLocale() from next-intl/server reads headers() internally; draftMode() does too

A useful audit pattern: grep -rn "headers()\|cookies()\|draftMode()\|getLocale()" app/ --include="*.tsx". Every hit inside app/layout.tsx or any shared layout component is a potential perf regression.

FAQ

Why didn't generateStaticParams + dynamicParams = false override the dynamic opt-in?

They don't override — they're subordinate to dynamic API detection. Next.js' rule: if any dynamic API is reachable from the render tree at runtime, the route renders dynamically, regardless of what generateStaticParams says. The static HTML is still generated at build time (that's why .next/server/app/**/*.html exists), but it's treated as unusable.

Does export const dynamic = "force-static" fix this by itself?

Not if a dynamic API is actually called at runtime. force-static tells Next.js "I promise this route is static" — and it will throw at runtime if you call headers() or similar. So it's a useful assertion (it catches the bug explicitly), but the real fix is removing the dynamic API call itself.

What if I genuinely need the locale in root layout, not in [locale]/layout?

You usually don't. If you have non-localized routes that need <html> too (e.g., a root-level not-found.tsx), give each of them their own <html> wrapper, and keep root layout as a passthrough. This is the pattern next-intl's official example uses.

Will cacheComponents / 'use cache' make this obsolete?

Eventually. Next.js' upcoming Cache Components model lets you selectively cache parts of a dynamic page. next-intl's support is tracked in issue #1493 and isn't merged yet. Until then, the canonical fix above is what works on production.

How did this regression ship past CI?

It didn't trigger any checks. TypeScript compiled. Lint passed. Build succeeded. The route classification output (ƒ vs ) is in the build log but easy to skim past. The fix for my team: a CI step that greps the build output and fails if key routes aren't . A one-line guard worth having.

My site uses middleware.ts / proxy.ts — does that force dynamic rendering too?

It can. If your middleware calls NextResponse.next({ request: { headers: modifiedHeaders } }) — modifying request headers — matched routes are often forced dynamic. Middleware that only redirects, rewrites, or sets response cookies doesn't cascade the same way. Audit what your middleware does on the hot paths.

Wrapping Up

A one-line SEO fix turned into a two-week performance regression because the framework silently widened it from "set <html lang> per request" to "re-render every page per request." The cost was TTFB going from a potential 100ms to an observed 1260ms — a 12x slowdown across 440 article pages.

The fix itself is three files (root layout, [locale]/layout, not-found). The learning is process: the three-command verification above belongs in every App Router deploy checklist, not just as a post-mortem reflex.

If you're running Next.js App Router with next-intl (or any i18n library that touches headers), open your root app/layout.tsx right now and look for any await that resolves a header, cookie, or locale. That line is costing you TTFB you didn't know you were paying for.