Skip to content

fix: stop og and llms.mdx routes from caching made-up paths - #16

Merged
josemontesdeoca merged 3 commits into
mainfrom
route-handler-dynamic-params
Sep 18, 2026
Merged

josemontesdeoca merged 3 commits into
mainfrom
route-handler-dynamic-params

Conversation

@josemontesdeoca

Copy link
Copy Markdown
Member

Why the change

The og and llms.mdx route handlers rendered and saved a new file for any made-up name under a real page (such as /og/setup/anything.png), and Next never deletes those files, so scanners could fill the disk; now both routes serve only what the build generated, and CI fails if a request adds a cache file or if any dynamic route can render on demand.

Special things to note

  • Some URLs that used to return 200 now return 404: made-up file names under a real page, plus bare /llms.mdx and /llms.mdx/setup, which both served the index page's markdown. Nothing on the site links to them. Page metadata builds these URLs with getPageImage / getPageMarkdownUrl, and the proxy's markdown rewrites end in content.md. Unknown paths under both prefixes now get the HTML not-found page with Cache-Control: no-store instead of an empty 404.
  • Each rejected og or llms.mdx request logs one 3-line Error: Internal: NoFallbackError stack. The handler itself rejects without logging, but Next then tries the next matching route, the [[...slug]] page, and that rejection is what gets logged. Unknown pages have logged the same line since fix: stop encoded probes from poisoning the robots.txt cache #15 (upstream: NoFallbackError logged to console.error when dynamicParams = false rejects a param vercel/next.js#90537). It replaces the Failed to update prerender cache warning that unknown og and llms.mdx pages used to print on every request.
  • The new prerender-manifest check fails the smoke run for any dynamic route that does not export dynamicParams = false. If a future route really needs on-demand rendering, it has to be exempted in that check on purpose.

Change outline

Both handlers look a page up with the last slug segment dropped, so any file name under a real page resolved to that page. The fix is dynamicParams = false, the same guard #15 put on the page catch-all. With it, the build marks both routes fallback: false:

 .next/prerender-manifest.json → dynamicRoutes
-  "/og/[...slug]"          fallback: null    # render unknown params on demand, then store the result
-  "/llms.mdx/[[...slug]]"  fallback: null
+  "/og/[...slug]"          fallback: false   # not prerendered → 404
+  "/llms.mdx/[[...slug]]"  fallback: false
   "/[[...slug]]"           fallback: false   # since #15

What that does to a made-up path:

 GET /og/setup/anything.png        (likewise /llms.mdx/setup/other.md, /llms.mdx, /llms.mdx/setup)
   /og/[...slug]
-    run GET() for "setup" → Setup's image
-    save og/setup/anything.png.body + .meta      # never deleted
-  200 image/png
+    not prerendered → NoFallbackError, GET() never runs
+  next matching route, /[[...slug]] → not prerendered → NoFallbackError (logged)
+  404 not-found page, Cache-Control: no-store, nothing saved

The if (!page) notFound() checks stay. TypeScript needs them to narrow page, and next dev, which has no prerender manifest, still relies on them for its 404.

CI runs scripts/smoke-standalone.ts against both the public and the preview build. The script gains two checks on the whole build and a probe for each URL shape:

 main
   assembleStandalone
-  build = { robotsBody, robotsMeta }
+  build = { robotsBody, robotsMeta, cacheTree }   # every path under .next/server/app, before the server starts
+  checkPrerenderManifest()                        # names each dynamic route whose fallback is not false
   startServer
   runChecks
     1-3. robots probe, public pages, unknown page   (unchanged)
+    3b. /og/setup/image.png → 200
+        /og/setup/smoke-nonexistent.png, /og/smoke-nonexistent-page/image.png → 404
+    3c. /llms.mdx/setup/smoke-nonexistent.md, /llms.mdx,
+        /llms.mdx/smoke-nonexistent-page/content.md → 404
     4. warm robots probe
     server output has no "app-route received invalid cache entry"
+    server output has no "Failed to update prerender cache"
+    cache tree has no path the snapshot lacks     # lists each new path on failure

The tree check catches writes nobody predicted, and the manifest check covers routes that no probe requests.

The og handler looks a page up with the last slug segment dropped, so a
request like /og/setup/anything.png rendered Setup's image and stored a
new copy under the requested name. Next never evicts those files, so
every made-up name grew the cache for good. Unknown pages failed to
store and logged a cache-write warning on every request.

Every image is generated at build time, so set dynamicParams = false:
anything else now 404s before the handler runs and nothing is written.

The standalone smoke run now snapshots the cache directory before the
server starts and fails if any request added a path. It probes a
made-up og file name and an unknown og page, and fails on the
cache-write warning.
The markdown handler has the same shape as the og one: it looks a page
up with the last slug segment dropped, so /llms.mdx/setup/anything.md
served Setup's markdown and stored a copy under the requested name.
Its optional catch-all also matched bare /llms.mdx and /llms.mdx/setup,
which served the index page's markdown and stored that too.

Every markdown file is generated at build time, so set
dynamicParams = false. The canonical files and the root markdown
rewrite are unaffected; every other path now 404s without writing.

The standalone smoke run probes a made-up file name, bare /llms.mdx
and an unknown page, and the existing cache-tree check covers the
disk side.
The request probes only cover routes someone remembered to probe. A new
dynamic route without dynamicParams = false would render any params it
matches on demand and store each result on disk, and nothing would
notice until the cache grew.

Before the server starts, the smoke run now reads the standalone
build's prerender manifest and fails on any dynamic route whose
fallback is not false, naming each one. That is the field the server
consults at request time, so the check holds for every route, probed
or not. The header comment now maps the route-handler guard to the
checks that cover it.
@josemontesdeoca josemontesdeoca self-assigned this Sep 18, 2026
@josemontesdeoca
josemontesdeoca marked this pull request as ready for review September 18, 2026 20:33
@josemontesdeoca
josemontesdeoca merged commit e2ab9b0 into main Sep 18, 2026
4 checks passed
@josemontesdeoca
josemontesdeoca deleted the route-handler-dynamic-params branch September 18, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant