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 (
<html lang={locale}>
{/* ... */}
</html>
);
}
I needed the locale in the root layout to set <html lang>, 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 (
- <html lang={locale}>
+ <html lang="en">
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 (
<>
<script dangerouslySetInnerHTML={{ __html: 'document.documentElement.lang="es"' }} />
{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() -
searchParamsin a page or layout -
noStore()orconnection() -
export const dynamic = "force-dynamic"left over from debugging - a
fetchwithcache: "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)