One line in your root layout can make every Next.js page render on demand
I spent a week looking at the wrong things. The site is a small Next.js 16 App Router project on Netlify. Static content, a few client-side tools, nothing exotic. But every response came back with this: Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate Cache-Status: "Netlify Durable"; fwd=bypass, "Netlify Edge"; fwd=miss no-store . On a content site. Every request going to origin, every time. I assumed it was a Netlify configuration problem, because that is what the header looks like. It was not. The cause was four lines in my root layout, and the fix took two files. The symptom next build prints a legend at the end of the route list that is easy to skim past: โ (Static) prerendered as static content ฦ (Dynamic) server-rendered on demand Mine looked like this: โ ฦ / โ ฦ /about โ ฦ /guides โ ฦ /guides/image-size-for-web โ ฦ /compress-image ... โ โ /sitemap.xml Seventy-six ฦ . One โ , and the one static route was the sitemap. Every page in the project was being server-rendered per request. Not because any page asked for it, but because something above them did. The cause Here is the relevant part of my root layout: import { headers } from "next/headers"; export default async function RootLayout({ children }) { const headersList = await headers(); const pathname = headersList.get("x-publishpixel-pathname") || "/"; const locale = getLocaleFromPathname(pathname); return ( {/* ... */} ); } I needed the locale in the root layout to set , and the root layout is a Server Component with no access to the current path. So I had middleware set a header with the pathname and read it back with headers() . It works. It is also the whole problem. headers() is a Dynamic API. Calling it opts the route into dynamic rendering, because the output now depends on the request. And because I called it in the root layout, every route under it inherited that. One call, seventy-six dynamic routes. Netlify then does the correct thing for a dynamically rendered response: it refuses to cache it. no-store was not a misconfiguration. It was Netlify honouring what my app told it. This is the part I want to flag, because it is what cost me the week: the header made it look like an infrastructure problem. I read Netlify docs, I looked at netlify.toml , I checked the adapter. The answer was in my own layout. The fix The locale genuinely does depend on the URL. But I already had a client component deriving it, and I had not noticed: "use client"; import { usePathname } from "next/navigation"; export function LanguageProvider({ children, initialLanguage = "en" }) { const pathname = usePathname(); const routeLanguage = getLocaleFromPathname(pathname || "/"); const [language, setLanguage] = useState(initialLanguage); useEffect(() => { setLanguage(routeLanguage); document.documentElement.lang = routeLanguage; }, [routeLanguage]); // ... } usePathname resolves during server rendering too. In a statically generated route it resolves at build time, per route, because each route is prerendered separately. The provider was already receiving the right answer from the router. It was just throwing it away on the first render in favour of the initialLanguage prop that headers() was feeding it. So the state seeds from the route instead: -export function LanguageProvider({ children, initialLanguage = "en" }) { +export function LanguageProvider({ children }) { const pathname = usePathname(); const routeLanguage = getLocaleFromPathname(pathname || "/"); - const [language, setLanguage] = useState(initialLanguage); + const [language, setLanguage] = useState(routeLanguage); And the root layout stops reading headers entirely: -export default async function RootLayout({ children }) { - const headersList = await headers(); - const pathname = headersList.get("x-publishpixel-pathname") || "/"; - const locale = getLocaleFromPathname(pathname); - +export default function RootLayout({ children }) { return ( - + Two files. The middleware also stopped propagating a header nobody read any more. The result Before: 1 static route, 76 dynamic After: 77 static routes, 0 dynamic In production: | Before | After | | |---|---|---| Cache-Control | private, no-cache, no-store | public, max-age=0, must-revalidate | Cache-Status | Netlify Durable; fwd=bypass | Netlify Durable; hit | | TTFB | 0.34 - 0.44 s | 0.16 - 0.20 s | Roughly half the time to first byte, on content that had not changed at all. The tradeoff I accepted The root layout now serves lang="en" in the static HTML for every route, including the Spanish ones, and the provider corrects it on hydration. I looked at doing this properly with route groups and two root layouts (app/(en)/layout.tsx and app/(es)/layout.tsx ), which is the canonical solution and keeps lang correct in the raw HTML. I did not, because it meant relocating around sixty route directories for an attribute that Google has repeatedly said it ignores for language detection - the signals that actually matter are hreflang and the visible content language, and both were already correct. Accessibility was the real concern, so the Spanish subtree gets a small layout that fixes the attribute before hydration: export default function SpanishLayout({ children }) { return ( <> {children} ); } Worth being explicit that this is a compromise, not a best practice. If you are starting a multilingual App Router project from scratch, use route groups with separate root layouts and skip this entirely. How to check your own project Run next build and count the ฦ markers. If routes you expect to be static are dynamic, something is calling a Dynamic API above them. The usual suspects: - headers() andcookies() - searchParams in a page or layout - noStore() orconnection() - export const dynamic = "force-dynamic" left over from debugging - a fetch withcache: "no-store" in a layout The higher up the tree the call is, the more it costs. In a leaf page it makes one route dynamic. In the root layout it makes all of them dynamic. One thing that helped me: check the legend, not just the list. It is easy to read seventy route names and never notice they all carry the same marker. The project is PublishPixel, a set of browser-based image checks that run locally without uploading anything. The measurements above come from its production deployment; the social media image sizes reference is the page I was profiling when I noticed the header. If you have hit the same thing with a different Dynamic API, I would like to hear which one - I suspect searchParams in a layout catches more people than headers() does. Top comments (0)
Comments
No comments yet. Start the discussion.