Prerendering a page per device, and the cache key that has to agree
A marketing page with no per visitor content should be built once and handed out as a file, which is what prerendering means here: the build writes the HTML, and the server answers with it instead of rendering again. The page I had to do this to was not quite static. Its footer is an accordion on a phone and a grid on a laptop, and a few sections below it differ the same way. Two rendered versions of one document have to exist at the same time. The browser can make that choice by itself, which is the first thing to rule out. A CSS media query renders both versions and hides one, so the same text sits in the document twice and every visitor downloads the one they will never see. JavaScript picks after the page has loaded, so the first paint is a guess and the reader watches it change. Either way the page ships markup the device in front of it will not use. Choosing at build time means one version per file, and the HTML a crawler reads is the HTML a phone gets. Everything below happens inside the application, in the Next.js App Router, on version 15. The same split can live a layer above it instead. Inside the application, that means two files and one URL. A shared cache in front of it keys on that URL and nothing else, so on its own it cannot tell the two files apart. What it can do instead is split its own storage by device, and making my split agree with its split decided the shape of everything else. The whole thing, before the explanation Two prerendered files per page, one public URL, and a rewrite that picks between them before the request reaches a route. Everything below this section is the reasoning. This is the code, and it runs. A page opts in with one export, and refuses to render at its own path, where no device arrives: app/terms-of-use/page.tsx import Layout from '@/app/_layout/Layout' import type { DeviceSegment } from '@/lib/device' import { notFound } from 'next/navigation' export const isDeviceBasedSSG = true export const metadata = { title: 'Terms of use' } export default function TermsPage({ device }: { device?: DeviceSegment }) { if (!device) notFound() return Terms of use } A script turns that marker into one route per device, plus the list of paths that got one: scripts/generate-device-routes.mjs import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' const DEVICES = ['desktop', 'mobile'] const SOURCE_DIR = 'app' const OUT_DIR = join('app', '(generated)') const PATHS_FILE = 'lib/cacheable-paths.generated.ts' const MARKER = /export\s+const\s+isDeviceBasedSSG\s*=\s*true/ // A folder named `(group)` adds no segment to the URL. A folder named `_private` is out of // routing altogether, so a page under one is not a route and must not get a wrapper. const isGroup = (name) => name.startsWith('(') const isPrivate = (name) => name.startsWith('_') function findMarkedPages(dir, routePath = '') { const entries = readdirSync(dir, { withFileTypes: true }) const here = entries.some((entry) => entry.isFile() && entry.name === 'page.tsx') && MARKER.test(readFileSync(join(dir, 'page.tsx'), 'utf8')) ? [{ routePath: routePath || '/', dir }] : [] return [ ...here, ...entries .filter( (entry) => entry.isDirectory() && !isPrivate(entry.name) && join(dir, entry.name) !== OUT_DIR, ) .flatMap((entry) => findMarkedPages( join(dir, entry.name), isGroup(entry.name) ? routePath : `${routePath}/${entry.name}`, ), ), ] } // The import path goes into a string literal and never into a comment: a directory // name may carry a line terminator, which ends a // comment and drops the rest into // code the build compiles. const wrapper = (importPath, device, metadataExport) => `// Generated. Do not edit. import Page${metadataExport ? `, { ${metadataExport} }` : ''} from ${JSON.stringify(importPath)} export const dynamic = 'error' ${metadataExport ? `\nexport { ${metadataExport} }\n` : ''} export default function GeneratedDeviceRoute() { return } ` const marked = findMarkedPages(SOURCE_DIR) for (const { routePath, dir } of marked) { // A wrapper is a route at a fixed path, so it cannot carry a parameter, and it sits // directly under the app root, so it cannot inherit a layout from the source's folder. if (routePath.includes('[')) { throw new Error(`${dir}: a device route cannot hold a dynamic segment (${routePath})`) } const ancestors = routePath.split('/').filter(Boolean).slice(0, -1) for (let i = 0; i < ancestors.length; i += 1) { const layout = join(SOURCE_DIR, ...ancestors.slice(0, i + 1), 'layout.tsx') try { readFileSync(layout) throw new Error(`${dir}: ${layout} would not apply to the generated route`) } catch (error) { if (error.code !== 'ENOENT') throw error } } } rmSync(OUT_DIR, { recursive: true, force: true }) for (const { routePath, dir } of marked) { const source = readFileSync(join(dir, 'page.tsx'), 'utf8') const metadataExport = /export\s+(async\s+)?function\s+generateMetadata/.test(source) ? 'generateMetadata' : /export\s+const\s+metadata\b/.test(source) ? 'metadata' : '' for (const device of DEVICES) { const outFile = join(OUT_DIR, device, routePath, 'page.tsx') const importPath = relative(dirname(outFile), join(dir, 'page')).split('\\').join('/') mkdirSync(dirname(outFile), { recursive: true }) writeFileSync(outFile, wrapper(importPath, device, metadataExport)) } } writeFileSync( PATHS_FILE, `// Generated. Do not edit.\nconst CACHEABLE_PATHS = ${JSON.stringify( marked.map((page) => page.routePath), )} as const\n\nexport default CACHEABLE_PATHS\n`, ) console.log(`generated ${marked.length * DEVICES.length} routes for ${marked.length} pages`) Run it before anything that reads the routes, and keep what it writes out of the repository: package.json { "scripts": { "generate:routes": "node scripts/generate-device-routes.mjs", "predev": "npm run generate:routes", "prebuild": "npm run generate:routes", "pretypecheck": "npm run generate:routes", "pretest": "npm run generate:routes" } } .gitignore # Written by scripts/generate-device-routes.mjs. app/(generated)/ lib/cacheable-paths.generated.ts Middleware rewrites the public path into the generated route, so the visitor's URL and the canonical tag never carry a device: middleware.ts import CACHEABLE_PATHS from '@/lib/cacheable-paths.generated' import { deviceFromHeaders, isInternalDevicePath } from '@/lib/device' import { NextResponse, type NextRequest } from 'next/server' export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // Nothing links to the internal form, so a request carrying one was typed or crawled. if (isInternalDevicePath(pathname)) { return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 }) } if (!(CACHEABLE_PATHS as readonly string[]).includes(pathname)) return NextResponse.next() return NextResponse.rewrite( new URL(`/${deviceFromHeaders(request.headers)}${pathname}`, request.url), ) } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], } One device read that every page shares, and the guard the middleware just used: lib/device.ts import { userAgent } from 'next/server' export const DEVICE_SEGMENTS = ['desktop', 'mobile'] as const export type DeviceSegment = (typeof DEVICE_SEGMENTS)[number] const LAYOUT_BY_CDN_TYPE: Record = { mobile: 'mobile', tablet: 'mobile', desktop: 'desktop', } export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return userAgent({ headers }).device.type === 'mobile' ? 'mobile' : 'desktop' } export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } And the response headers, because a prerendered route otherwise advertises a year of shared caching to whatever sits in front of it: next.config.mjs import CACHEABLE_PATHS from './lib/cacheable-paths.generated.ts' /** @type {import('next').NextConfig} */ export default { async headers() { return CACHEABLE_PATHS.flatMap((path) => [ { source: path, has: [{ type: 'header', key: 'rsc' }], headers: [{ key: 'Cache-Control', value: 'private, no-store' }], }, { source: path, missing: [ { type: 'header', key: 'rsc' }, { type: 'query', key: '_rsc' }, ], headers: [{ key: 'Cache-Control', value: 'public, max-age=0, s-maxage=3600' }], }, ]) }, } That is the implementation, on Next 15 with the App Router. Next 16 renames the file to proxy.ts and the function to proxy, which I have not run. The rest of this article is why each of those decisions is the one that survived, and the two failures none of the code shows on its face: a build that stays green while nothing is prerendered, and a cache that hands one visitor's layout to everybody else. The cache key is the path, and nothing from the request Next keeps the HTML it prerendered in what it calls the full route cache. The key for that cache is the resolved route path. In 15.5 it is one line of next/dist/server/base-server.js: let resolvedUrlPathname = getRequestMeta(req, 'rewroteURL') || urlPathname and getFilePath in the file system cache turns that same string into a path on disk. Nothing else from the request reaches it. Next does send a Vary header for whatever caches the response downstream, but its own route cache does not consult one. The query string does not help either. I measured that rather than reasoning about it: with the page not reading searchParams, ?device=m, ?device=d and no query at all came back byte for byte identical, all three hashing to 4005cda574c9, one cache entry answering every one of them. A page that reads searchParams is not prerendered at all, and whether that is a build failure or a silent fallback to rendering on demand is the subject of a later section. Rewrites declared in next.config do not create routes either, they point at routes that already exist, so they add no dimension to the key. I pointed one at a prerendered route from beforeFiles and another from afterFiles, and both source URLs came back identical to the route itself, all three hashing to d0fa5dfdb35c. One entry, three ways in. Two variants therefore mean two paths, and the only varying input a prerendered render can see is a route parameter. Where the shape came from I did not invent this. The shape comes from a comment by a Next collaborator in a discussion about serving a light and a dark theme out of one cache: Prerender per known theme generateStaticParams returns the theme list. Pages live under [theme]/. A proxy rewrites /foo → //foo server-side, and redirects direct visits to /light or /dark back to the unprefixed path so themed URLs aren't shareable. Each route ends up with one cache slot per theme. HTML arrives themed. No FOUC, no JS required. Server can branch on theme, that is, RTL, different components, distinct meta. Read it with devices in place of themes and it says four things. Make the variant a route parameter. Move the pages that want variants under that parameter's folder. Let generateStaticParams prerender one copy per value. Have middleware, the function that runs before a request reaches a route, rewrite the public URL into the variant so the visitor never sees it. He names two costs: Switching theme is a server round trip, an actual navigation. Proxy on every request, between you and your CDN. Neither reaches this case. Nobody switches devices, so there is no switch to pay for. And middleware was already running on every request here, for locale routing and for putting visitors into experiments, so its cost was on the bill before this feature existed. Two parts of that I did not want. A dynamic segment in the first position costs more than the URLs it agrees to match. Once the first segment is a parameter, every first level path is a valid route, and the framework's own not found stops firing for a wrong one, because from its side nothing is wrong. Something inside the tree has to decide which values are real and call notFound() for the rest, which makes that check the only place the legal values are written down. A reader who wants to know what the site accepts can no longer look at the folders, they have to open the code. A whitelist does not rescue it either, because Next has no fall through: once a route matches, notFound() renders the not found boundary rather than handing the request to the next candidate route. The shared parent folder fails more quietly. Every page that wants a variant has to move under it, and pages that cannot have one stay where they are. Say articles/ holds two pages, one that can be cached and one that reads a cookie and therefore cannot. The first moves under [theme]/ and the second does not, so articles/ now exists in two places at once. Add a page, and remember the second tree. Rename a folder, and remember it again. The build is green either way, and the mistake shows up as a page that quietly stopped being prerendered. So the parameter went away, and two literal folders replaced it, desktop/ and mobile/, each holding a real page file. A folder matches its own name and nothing else, so an unknown value now matches no route at all. That is what generateStaticParams and dynamicParams were there to arrange, and what the guard was there to catch when they failed, so all of it goes. What literal folders do not remove is the duplicated tree. They move it: desktop/ and mobile/ each hold a copy of every marked page. What changes is who keeps the copies in step. Two trees a person edits is the objection above. Two trees a script writes is a build step, because nobody edits them and nobody reviews them. That is the whole argument for generating them. What the generator writes The whole opt-in is one export, at the page's normal path: app/terms-of-use/page.tsx export const isDeviceBasedSSG = true The script writes two things from it: a route per device, and the list of paths that got one. The list is a module rather than a directory scan, because the same code has to work wherever the rewrite runs, and because deriving it from the marker is what stops it drifting away from the folders it names. Each generated route carries no logic of its own: app/(generated)/desktop/terms-of-use/page.tsx // Generated. Do not edit. import Page, { metadata } from '../../../terms-of-use/page' export const dynamic = 'error' export { metadata } export default function GeneratedDeviceRoute() { return } The wrapper takes no props of its own on purpose. A page file is type checked against the props Next passes a route, and a wrapper that declares anything else fails that check. Metadata is re-exported, and a wrapper that forgets it leaves the page with no title and no description, which is why the script reads the source for a metadata export and emits the re-export only when it finds one. The line that matters more than either of those is dynamic = 'error'. The one line that turns a silent failure into a build failure Nothing in a prerendered route's tree may read cookies(), headers() or searchParams. That includes the layout and the not found boundary, which sit in every route's tree. One such read anywhere puts the whole route back on per request rendering, and the build stays green while it happens. The not found page is the one that catches people out, and it is also the one page whose device genuinely cannot be known, so it takes a fixed value rather than reading for one. dynamic = 'error' is what turns that class of mistake into a build failure, and the failure names the API: Route /desktop/terms-of-use with dynamic = "error" couldn't be rendered statically because it used headers. The variant you serve has to be the variant the cache stored Everything above gets you two prerendered files and a rewrite that picks one. Put a shared cache in front and a new failure appears, because the cache stores one body per bucket of its own choosing, and it chose that bucket with its own classifier. Cloudflare, for one, can split a cache rule by device type and then sends the origin a CF-Device-Type header carrying the bucket it used. If middleware picks the branch with a User Agent parser while the edge keys on the CDN's answer, then for every user agent where the two disagree, whoever arrives first decides what the entire bucket serves to everyone behind them. They disagree on real traffic, and not on anything exotic. A native app's networking stack and a reading device asked to behave like a desktop both landed in one bucket at the edge and another in my parser. The particular agents will age, so take the category instead of the list: anything whose user agent does not describe the screen it draws on. Whatever you parse with has to collapse to the buckets the cache actually holds. Mine had a third answer, unknown, for anything it did not recognise, and under two buckets a third answer is not a third variant, it is a coin toss about which of the two documents a visitor gets. The read above has no third branch for that reason. Every disagreement I found became a test case. The fix is one server side read, used by every page, taking the CDN's answer first and the user agent second: lib/device.ts export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return parseUserAgent(headers.get('user-agent') ?? '').isMobile ? 'mobile' : 'desktop' } Tablets map to the mobile layout, which means three buckets hold two documents and the tablet copy duplicates the mobile one. That is the price of letting the cache own the classification. The internal paths have to be unreachable Nothing links to /desktop/terms-of-use, so a request carrying one was typed or crawled. It is also a real route, and I checked what happens without the guard: it answers 200 and serves the document. The same page at a second URL, with whatever search weight the real one has split between them. lib/device.ts export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } The rewrite goes to /_not-found with an explicit status: 404. Without the status it is a soft 404: the not found page at 200, which is the duplicate the guard exists to prevent, wearing a different hat. What I would keep Keep the variant as a path segment, because nothing else reaches the key. Keep literal folders rather than an enumerated dynamic segment, because a folder cannot match a value it is not named after. Keep one device classifier for the whole application, and if a shared cache sits in front, make it the cache's classifier. Keep dynamic = 'error' on every variant route. Keep a check that reads the prerender manifest rather than the route table, and a second one that requests the URLs nobody should request and expects a 404. Keep a check on the bytes themselves, which asks a running build for the same page twice and compares what comes back: same bytes inside one device class, different bytes between classes, and no change when the request carries cookies. Take the cookie names from the application's own constants rather than retyping them, or the check sends a cookie nothing reads and quietly passes on two requests that both carried nothing. Keep the generator, and know what you are buying with it. Two hand written wrappers of four lines each do the same job today, for two cacheable pages. The day somebody marks a third page, the wrappers and the path list have to follow, and a person who forgets gets no error, just a page quietly rendering per request again. A marker that generates both of them cannot be forgotten. That is the only thing the script buys. The price is a prebuild step in front of every command a developer runs, stack traces that point at files which are not in the repository, an error that names a wrapper when the mistake is in the source page, a tree that can be stale, and a script that writes paths into code the build then compiles. For two pages it is a bad trade. For a site where anybody can mark a page, I would make it again. The generated tree is in .gitignore, and that is the call in all of this I am least settled about. It keeps the repository honest about what a person wrote. It also means nothing in the repository shows a reader what actually gets built, and a stack trace can point at a file they cannot open. The code here is a rewrite of what shipped, generic enough to drop into any project. To be sure the rewrite is more than a sketch I built a clean Next 15.5 app out of exactly these files and drove it, and the build output, the response headers and the hashes above come from that app. What a local app cannot show is how two classifiers disagree on real traffic, and that part comes from the application the design shipped in.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to