PubTrivia has a page I use for judging the bits of the product that are purely visual: the mascot, the celebration effects, the player screens and the host's big screen, all on one scrolling page with made-up data so I can compare them side by side without starting a quiz.
It is not supposed to exist in production, and the way it does not exist is one line:
export const metadata: Metadata = { title: 'Mascot and effects lab', robots: { index: false, follow: false }, } export default function LabPage() { if (process.env.NODE_ENV === 'production') notFound() return <EffectsLab /> } Eighteen lines, including the comment. Behind <EffectsLab /> sit six files and 1,231 lines of preview UI: a 538-line phone preview, a 335-line gallery of effects, a 169-line mascot comparison, a 71-line host-screen preview, the lab shell and a shared heading component.
Then I went and looked at what that one line actually produces, which is more interesting than I expected.
It is not a runtime check, it is a build-time one
process.env.NODE_ENV is 'production' while the production build is running, so the condition is true at build time. Next does not defer anything: it renders the page, hits notFound(), and writes the result to disk as a static 404.
$ cat .next/server/app/play/lab.meta { "status": 404, "headers": { "x-nextjs-stale-time": "180", "x-nextjs-prerender": "1", "x-next-cache-tags": "_N_T_/layout,_N_T_/play/layout,_N_T_/play/lab/layout,_N_T_/play/lab/page,_N_T_/play/lab" } } Beside it is a 13,376-byte lab.html whose root element is <html id="__next_error__">, a lab.rsc payload, and a lab.segments directory holding the five segment payloads the .meta file's seven segment paths resolve to. The route is in prerender-manifest.json with its own data route. In other words, the absence has been built, versioned, uploaded and cached, exactly like a page.
The live response agrees:
$ curl -sI https://pub-trivia.app/play/lab | grep -iE 'http|prerender|vercel-cache|length' HTTP/2 404 x-nextjs-prerender: 1 x-vercel-cache: HIT content-length: 16474 A 404 with a cache hit. Not a function invocation, not a miss, not a dynamic render deciding anything: a static asset whose status line happens to be 404.
For contrast, a URL the app has never heard of does not get there at all. Typing /no-such-page-xyz gets a 307 to the sign-in form, because an unknown path is not on the list of things the public may reach. /play/lab 404s because it is under a prefix the public can reach, which is a slightly funny reason for a developer page to be the honest one.
1,231 lines did not ship, and one string did
The lab's own code is genuinely absent from production. The 404 page pulls 13 JavaScript chunks and 2 stylesheets, 1,062,507 uncompressed bytes and 320,369 over the wire with brotli, which is the app shell: framework, router, theme provider, the toaster, the error boundary. I fetched all 15 and searched them for the lab's strings:
lab-component strings found in served chunks: [] No EffectsGallery, no PhonePreview, no mascot. The guard runs before the component is referenced in the render, and the client reference manifest for the route never gets pulled into anything the shell loads.
The metadata export is a different story, because metadata is not inside the component. Next resolves it for the route regardless of what the component then does, so the name of a page that does not exist is published three times in the 404's own HTML:
<title>Mascot and effects lab | PubTrivia</title> <meta property="og:title" content="Mascot and effects lab | PubTrivia"/> <meta name="twitter:title" content="Mascot and effects lab | PubTrivia"/> Those arrive in the streamed metadata. The static <head> of the document, meanwhile, carries the site's default title, so the page briefly claims to be the homepage and then renames itself to a page that 404s. Both titles are in the same 16 KB response, which you can see with one grep.
This is the part worth taking away: a notFound() inside your component does not suppress the route's metadata. If the name of a route is something you would rather not publish, the guard has to be somewhere the metadata cannot escape: no title in the export, or the file excluded from the production build entirely.
Three small mercies in our case. The export sets robots: { index: false, follow: false }, so the response carries noindex, nofollow as well as the layout's own noindex. robots.txt disallows the whole /play prefix, so no well-behaved crawler asks in the first place. And the name is "Mascot and effects lab", which gives away nothing except that we have a mascot, which the homepage says too.
The build still priced it
The one cost I did not expect is in Next's own diagnostics. route-bundle-stats.json includes an entry for the route:
{ "route": "/play/lab", "firstLoadUncompressedJsBytes": 1186630, "firstLoadChunkPaths": [ ... ] } 1.19 MB of first-load JavaScript, costed for a route that cannot render anything but a 404, and the chunk list includes a 46,126-byte chunk holding the lab's components. That chunk exists in the local build output and is referenced only by the route's client reference manifest, so nothing ever requests it. It is in the build-size report, which means a performance budget keyed on route stats would be reporting on a page nobody can open.
Would I do it differently?
Probably not, and this is the useful conclusion rather than a confession. The alternatives are worse:
- Delete the lab when done. Then it has to be rebuilt the next time the effects change, and the comparison I actually need is "this one against last month's one".
- Put it behind an auth check. More code, a real surface in production, and an operator account can then accidentally be shown a page of fake data.
- Keep it in a separate dev-only app. Then it imports the real components across a package boundary, and the point of the lab is that it renders the real screens.
What the measurement changes is the follow-up list: do not put anything in that metadata export that is not already public, and do not trust a route-level bundle report that includes routes which cannot render. One line of guard, one static 404, 16 KB of HTML, and a name in three tags.
Check it yourself
$ curl -s https://pub-trivia.app/play/lab | grep -o 'Mascot and effects lab | PubTrivia' | wc -l 3 $ curl -sI https://pub-trivia.app/play/lab | grep -i 'http\|prerender' HTTP/2 404 x-nextjs-prerender: 1 The page that answers is nothing, three times over, and then names itself. Everything it was built to preview is visible instead in the demo on the homepage, which runs the real screens with a made-up round in them, and on the big-screen feature page.


