The mobile Lighthouse report for staging on 15 September 2026 scored home at 67, about at 70 and events at 74. The LCP was above six seconds, and the cause it pointed to was document request latency, the time spent before the HTML document itself reaches the browser. I tied it to one thing: every public page was rendered per request, on Vercel Hobby with a Neon database.
Before building any fix, I measured staging first. Cold TTFB was 4.7 s, warm TTFB was 0.75 to 1.1 s, and every page carried the same pair of headers: no-store and x-vercel-cache: MISS.
Those two headers already said where the fix had to go. no-store tells every cache not to keep the response, and MISS means the Vercel edge had no copy to serve, so every visit waited for a server function to render the page from scratch. From that measurement I decided the fix had to be pages served from the edge, not cached queries.
The cost was the document, not the queries
The site is a Next.js App Router project with Payload as the CMS: multi-tenant, three languages, and a member area. When the slow part is a query, caching its result is the answer. Here the slow part was the document. The commit message for the fix sums it up: the largest cost was the document itself, because every page read the request (the host for the tenant, a header for the language, the cookies for the member) and so was rendered per request, with a cold start of several seconds on Vercel.
In the App Router, headers() and cookies() are dynamic APIs. A server component that calls either one during render means the route cannot be produced once at build time, because its output depends on a request that does not exist yet. The route is rendered again for every visit and sent with no-store. The call does not have to live in page.tsx either. One component that appears on every page, such as a header, is enough to drag every page into per-request rendering. Cached queries do not change that, because the render still happens for every visitor.
Everything that read the request
The list can be read straight from the code before the fix. The header called a session reader on the server so a signed-in member was greeted by name on every page, and for that member the invitation and sign-in actions were emptied. The footer called the same function to swap the call to action under the tagline for a link to the member area. That session reader read a cookie on every request: first the session from a hosted login provider, then Payload's built-in session through payload.auth with the request headers.
That only covers the member. The tenant lookup read the host header on every call to match it against a tenant's domain. The language came from a request header written by the middleware. The language switch links in the header, the phone menu and the footer were built from another header carrying the path. The collection list block called the session reader as well, then passed the member and the request headers to the Payload query. Even the sitemap stayed dynamic, because the function that fetches the site's global settings read the request.
// Before the fix. Simplified, function and header names changed.
// Header and Footer, on every page
const member = await getMember() // cookies(), then headers()
// tenant lookup, on every call
const host = (await headers()).get('host')?.split(':')[0] ?? ''
// language, from a header the middleware wrote
const lang = (await headers()).get('x-lang')
// language switch links
const path = (await headers()).get('x-path') || '/'Any one of those lines is enough to make a page dynamic on its own. So there was no single line to delete that would end the problem. As long as one reader remains in the render path of a public page, that page is rendered per request.
Language moves into the URL, pages are kept for an hour
Every frontend route moved under a [lang] segment. The middleware rewrites /events to /en/events, leaves /fr/events as it is, sends /en/... to the bare address with a 308 so each page has one address, and passes anything with a file extension through untouched. The language is then read from that segment through next/root-params in a server component. The language header is only a fallback in a server action or a route handler, so no page reads the request to learn its language anymore.
With the language as part of the route, a page can be rendered without waiting for a request. The [lang] layout sets revalidate = 3600 and generateStaticParams for the three languages. The catalogue page prerenders every published page in every language at build time, and other addresses are rendered on their first visit. The sitemap reads the offered languages without the page language and is kept for an hour.
The tenant got a simpler answer. The tenant lookup reads the host only when an environment variable is 1, and .env.example spells out the price.
// After the fix. Simplified, names changed.
// app/[lang]/layout.tsx
export const revalidate = 3600
export function generateStaticParams() {
return LANGS.map((lang) => ({ lang }))
}
// tenant lookup
const host = process.env.MULTI_DOMAIN === '1'
? ((await headers()).get('host')?.split(':')[0] ?? '')
: ''
// .env.example:
// Set to 1 only when more than one tenant answers on its own domain: the host
// is then read on every request and no public page is kept.The language switch links could no longer come from a path header. They are now built in the browser from the address bar by a client component, because the header and the footer are rendered once for every page and so cannot know which page they are on.
Kept pages also need a way out when content changes. afterChange and afterDelete hooks on every content collection and on both globals call revalidatePath('/', 'layout'). Outside a request, in the seed or a script, Next refuses the call, and the code logs that once and swallows it.
// Simplified. Registered as afterChange and afterDelete
// on every content collection and on both globals.
try {
revalidatePath('/', 'layout')
} catch (error) {
// Outside a request (seed, script) Next refuses this call.
// Logged once, swallowed after that.
}The member's name moves to the browser
The member greeting changed the most. The header and the footer no longer read the session at all. In their place is a context provider on the client. A visitor without a flag cookie is treated as anonymous and costs nothing, and only a visitor carrying that flag is asked about at a new route handler, /whoami. That route reads the session from the cookies on every call, is never kept by any cache, and answers with the member's name or {} under Cache-Control: private, no-store, max-age=0.
The flag is a separate cookie with the value 1, set beside the session cookie with httpOnly: false. According to its comment it carries no secret, only the fact that a session exists, and it goes when the session goes. The httpOnly: false is deliberate: a cookie marked httpOnly is invisible to document.cookie, and this flag exists to be read by a script.
// Simplified, names changed.
// set beside the session cookie, removed with the session
jar.set('signed-in', '1', { httpOnly: false, sameSite: 'lax', secure, path: '/', expires })
// app/[lang]/whoami/route.ts
export async function GET() {
const member = await getMember()
const body = member ? { name: displayName(member) } : {}
return NextResponse.json(body, {
headers: { 'Cache-Control': 'private, no-store, max-age=0' },
})
}The browser takes two steps. An inline script in the layout marks the root element with a class before the first paint when the flag cookie is present, and CSS hides the guest actions under that class, so the invitation and sign-in buttons never flash in front of a member. After that, the client provider asks /whoami for the name.
// Simplified, names changed.
// inline script in the layout, runs before the first paint
if (/(^|; )signed-in=1(;|$)/.test(document.cookie))
document.documentElement.classList.add('is-signed-in')
// CSS: .is-signed-in .guest-only { display: none; }
// client provider, only for visitors carrying the flag
fetch('/whoami', {
credentials: 'same-origin',
cache: 'no-store',
headers: { accept: 'application/json' },
})Members pay a small price. The collection list block now reads as a visitor, so a page shows only what anyone may see. Gated items keep their teasers and open from their own pages, which means members lose only the "unlocked" marks inside list blocks. The pages that genuinely read the session stay rendered per request on purpose: the member area, the directory, join, sign in, the invitation page, and the event and content detail pages.
Proof on the production build and on staging
On next start, the time to first byte of /, /about, /events, /fr and /es/events fell from 0.5 to 1.0 s to under 10 ms, with x-nextjs-cache: HIT and s-maxage=3600. The route list from the build showed 39 catalogue pages and the sitemap as prerendered and the member routes as dynamic, with tsc and 96 tests green.
The personalization was checked from both sides. A member signed in through the built-in form saw the greeting with their name, while a fresh browser got the cached page without it, and /whoami answered {}. Invalidation was checked too: an SEO title edited through the REST API turned /about, /fr/about and /es/about into a MISS with the new title, and they came back after the title was restored.
The first invalidation attempt actually looked like a failure. The server had been restarted between the edit and the check, and next start keeps its revalidation marks in memory, so they were gone with the process. On Vercel they persist. If you test revalidation locally, do not restart the server in the middle of the test.
After the fix was deployed, every public page on staging answered x-vercel-cache: HIT, or PRERENDER on the first request after a deploy. The server part of the time to first byte was about 100 ms from Bali. The same check recorded /en/about answering 308, /whoami answering {}, the redirect to the hosted login, the callback and the 404 all behaving as before, and no console errors in the browser at 1440 or 390 wide. The full login round trip with a test member account was walked as well: hosted login, callback, the member area, then the named greeting on the cached home page with the anonymous actions hidden, then logout.
before after
next start, TTFB 0.5 to 1.0 s under 10 ms, x-nextjs-cache: HIT
staging, warm TTFB 0.75 to 1.1 s about 100 ms (server part, from Bali)
staging, cold TTFB 4.7 s
x-vercel-cache MISS HIT (PRERENDER on the first request
after a deploy)Lighthouse did not move
The Lighthouse report was the reason for this work, and at the time I expected the cache pass to answer it. It did not. Lighthouse mobile 13.4.1 with simulated slow 4G scored home at 59 then 68, about at 71 and events at 67, against a baseline of 67, 70 and 74. A TTFB that dropped from seconds to about a hundred milliseconds did not show up in that score. The explanation I wrote down: simulated throttling barely rewards server time, and the page's own bytes decide the score.
The bytes were real. The LCP element on home is a photograph, and that run loaded all 96 frames of the hero image sequence, 3 MB of a 4 MB page, because a six second timer released the rest of the sequence while the audit was running. The three preloaded fonts added 329 KB. The next commit made the opening 24 frames wait for the load event and the rest wait for the first scroll. Checked at 390 wide, that gave 24 frames after load and 96 after a scroll. The fonts were not touched in that commit.
After that deploy, home on staging scored 69 then 77, with an FCP of 1.7 s, an LCP of 6.1 s, 24 frames and a 1.8 MB page. The comparison is 59 and 68 before that commit and 67 at the baseline, with a run to run variance of about eight points. The page went from 4 MB to 1.8 MB, yet the LCP was still above six seconds.
When a kept page actually changes
After the fix, a few notes show when a kept page does not change right away. A hand edit in the database does not fire the revalidation hooks, so on 15 September the push that carried the seed change was what refreshed the kept pages. On 29 September I noted that the first request after a seed still serves the cached page (stale-while-revalidate), so a page has to be warmed before it is checked. On 1 October, the FR and ES translations that had been applied were already in the database, but the staging pages kept serving the build's cache until I pressed Save in the admin.
So when the database is right but the page is not, check which path the change came in through, and warm the page before judging it.
One rule for public pages
The architecture notes I wrote after this fix put the condition in a single sentence: for the pages to stay kept, nothing on a public page may read the request. That rule is what separates this case from an ordinary data caching problem. What needs guarding is not the cache setting of a fetch but every component in the render path of a public page, including the header and footer that appear everywhere.
If every page of your Next project carries no-store and x-vercel-cache: MISS, start with the route list from next build, which marks each route as prerendered or dynamic. Then look for calls to headers() and cookies() in the components every page uses, not only in page.tsx. The parts that truly belong to one visitor, like a member's name, can be fetched by the browser from a private, no-store route handler, with a flag cookie a script can read so anonymous visitors never have to ask. Once TTFB is fixed, do not expect a simulated Lighthouse score to follow. By the explanation I recorded, what decides that score is the page's own weight.