ADR-0034 — The proxy answers unknown paths with a true 404

The proxy answers every unknown path with a 404 status and the whole out-of-orbit page from the first request, instead of a blank 200.

Context

  • Every path that is not a site route reaches src/app/(site)/(docs)/[...slug]/page.tsx, which calls notFound() when it has no such page.

  • That call comes too late for a path generateStaticParams did not list. Measured on bunx next build, for a path not requested before:

    RequestStatusHTML
    First /no-such-page200Blank: <html id="__next_error__">, no page content
    The same path again404Blank, the same shell
    First /x/y/z, /docs/nothing-here200Blank, the same shell

    The route's prerendered fallback shell is empty, and the page learns the path is unknown only when it resumes. Scripts then build the 404 in the browser, with a noindex tag.

  • So a reader without scripts sees a blank page, which contradicts the 404's documented state (Back to the system still works). Monitoring counts these as successes, some crawlers report soft 404s, and in Chrome the first hit's tab showed no title although the title is in the HTML.

  • The installed Next.js documents this. A streamed page keeps its 200, and for a 404 "you can check the slug in Proxy, then rewrite it to a not-found route or return a 404 response" (file-conventions/loading.md, Status codes).

  • dynamicParams = false, which would refuse unlisted paths, is not available with Cache Components (ADR-0016; route-segment-config/dynamicParams.md).

  • The proxy has no matcher, so it runs on every request, including _next and files. It already answers line redirects and Markdown, but only for catalogued slugs, and it knows only meta.json files: src/lib/catalog-routes.ts keeps compiled pages out of its bundle.

  • fumadocs-mdx/macro has an async mode (docs: { async: true }): frontmatter is imported at once and the compiled body only when asked for (transform-*.js, only: "frontmatter").

Decision

  1. The proxy answers every unknown path with a true 404, before any page renders. The status is 404 from the first request and the response is the whole out-of-orbit page, the existing src/app/not-found.tsx, in the HTML. The address does not change, and the page still shows no part of it.
  2. What it judges. Only paths [...slug] would otherwise render. These pass through untouched: /, /_next/…, the site's own routes (api, diagrams, llms-full.txt, llms.mdx, llms.txt, og, static.json), icon.svg and any file in public/.
  3. What it knows. Every page's URL, and each /<slug>/use and line root that redirects to a first page (ADR-0015 decision 1). Comparison is exact, so /DOCS is unknown.
  4. The existing order stays. Line redirects and Markdown come first, so a retired line still redirects and /<unknown>.md still gets its own 404 from the Markdown route.
  5. The list comes from the content, and the build checks it. catalog-routes.ts lists the pages' paths through an async collection, so no compiled page runs in the proxy, and works out each URL with the same line segments as source.ts. The build fails if this list and source.getPages() differ, because a page wrongly refused would be a real page gone.
  6. notFound() in the page stays, as the safety net for anything that reaches it.

Options considered

OptionProsConsVerdict
Check every path in the proxyTrue 404 with full HTML everywhere; what Next.js documentsThe proxy must know the pages; a wrong list would hide a real page, so it needs a build checkChosen (owner)
Check only the first segmentUses data the proxy already has/docs/nothing-here stays a blank 200Rejected (owner)
Leave itNo work; noindex already keeps these out of searchBlank without scripts, 200 in monitoringRejected (owner)
dynamicParams = falseOne line, no listNot available with Cache ComponentsNot possible
Generate the list in a script, or in next.config.mjsNo macro involvedA new build step, and it can go stale in developmentRejected
Read content/ from the proxy at request timeAlways currentSlow, and the content need not be on the serverRejected
Fetch from a route in the proxyReuses sourceA request per request; Next.js advises against itRejected

Consequences

Positive

  • Unknown paths are true 404s for readers, tools and monitoring, and readable without scripts.
  • The 404's documented degraded state becomes true.

Negative / accepted trade-offs

  • The proxy gains a second view of the pages. The build check is what makes that safe.
  • It runs one set lookup on each request it judges.
  • The async collection adds lazily loaded chunks to the proxy's bundle. They are not run, but their size is unmeasured until this is built.
  • A new top-level route must be added to the proxy's pass-through list, or it would answer 404. A test compares that list with src/app/.

Content invariants

§6.5 is kept: Markdown routes are not changed. No content is added or exposed. The URL list holds only pages that are already published.

Security considerations

Low. The request's path is only compared with a fixed set, and it is never echoed, so the page still shows no part of it. Nothing leaves the system and no secret is touched. The proxy's bundle holds the same public URLs as llms.txt. An unknown path now gets a cached static page, which costs less than a render. The threat model is checked on implementation.

Implementation notes

  • First, verified with a spike on bunx next build: a proxy rewrite to /_not-found with status 404 serves the whole page on the first request, for document and _rsc requests alike. The ADR's other route was not needed.

  • Measured the proxy's traced chunks: 512 KB before; 2.9 MB with a plain async collection, because it compiles a lazy chunk with the text of every page, which the proxy never loads. That was out of proportion, so the collection in catalog-routes.ts compiles each body empty, with a one-line remark plugin of its own. The result is 585 KB, with no page text in any proxy chunk, and the site's own collection keeps its bodies. The chunk loaded at start grows by about 20 KB, the frontmatter. A request took 1.5ms at the median through the proxy.

  • src/lib/known-paths.ts: the site routes, knownPaths and knownPathProblems, without the macro so they can be tested. src/lib/catalog-routes.ts: the collection, isKnownPath and assertKnownPaths. src/proxy.ts: the check, after the existing steps.

  • lineSlugs in src/lib/catalog-plugin.ts, used by source.ts and known-paths.ts, so both work out a line's URL the same way.

  • The build check of decision 5: checkKnownPaths in src/lib/catalog.ts, called from the page's generateStaticParams, comparing the list with source.getPages() and the catalogue's landings. Dropping one page from the list fails bunx next build and names that page.

  • Tests (ADR-0007), in src/lib/known-paths.test.ts: known pages, landings, line segments, unknown paths at each depth, the wrong case and trailing slashes; each pass-through; the pass-through list against src/app/ and public/; the check failing on each kind of difference. Checked with curl against bunx next build:

    • the Context table's paths give 404 with the page in the HTML on the first request, and no part of the path in it;
    • the 93 pages in llms.txt and in static.json give 200;
    • the site's routes and files are unchanged, /<entry>/<unknown>.md still gets the Markdown route's 404, and /docs/ still redirects to /docs.

    In Chrome: the tab's title on a first hit; the page with scripts off, in a frame sandboxed without allow-scripts, whole and with its links; a client-side push to an unknown path; seven real prefetches, all 200; Back to the system and an ordinary link staying client-side.

    In development, against next dev: a page added to content/ answered 200 within four seconds, and 404 again within four seconds of being removed, so the proxy's list does not go stale there.

  • Found while checking:

    • A client-side link to an unknown path now loads the whole page rather than swapping it in, because Next.js does that for any RSC response that is not OK (fetch-server-response.js). The reader lands on the same page.
    • The 404 is served with Cache-Control: s-maxage=31536000, as a static page is. A shared cache that keeps it across a deploy would hide a page added later. No host is adopted yet; the ADR that adopts one must check this.
    • Decision 2 leaves everything below a site route alone, so a path such as /api/nope or /diagrams/nope, which only [...slug] matches, is still a blank 200. /og/nope is a 404 from its own route. ADR-0035 later removed api from the site routes, since no such route exists, so /api/nope is now a true 404; /diagrams/nope and /llms.txt/x are still blank 200s.
  • bun run test, bun run types:check and bunx next build pass; bun run lint is reported while it stays blocked.

Docs to update on implementation

On this page

8 min read