How to stop Next.js from caching notFound() renders for invalid dynamic route params? #98310
Replies: 7 comments 2 replies
|
Your reading of the two cache layers is correct. In the current Cache Components model there is no per-result switch that means “cache successful params, but do not persist this The current docs make the boundaries fairly explicit:
So for question 1, your gate is currently the supported shape. Have One adjustment: I would not make correctness depend on a mutable module-level For question 2, I cannot find a public roadmap commitment. The linked #84991 still has no accepted or maintainer answer committing to an API, and the current route-segment documentation still records That leaves three supported trade-offs today:
Given your requirements, option 1 is the only current combination that preserves on-demand ISR for a growing catalog while placing a hard boundary in front of unbounded negative params. Official references:
|
|
We are working on an API to allow you to control this behavior, it is a bit early to share impl and API details, but it'll happen soon. |
|
we had this same problem on a project with user generated content slugs. the core issue is that the caching layer and the rendering layer are two separate systems. use cache only controls the data fetching cache, meaning the return value of your cached function. but the App Shell upgrade mechanism operates at the routing level. when a request comes in for a dynamic param that wasnt prerendered, the router serves the shell immediately then does a background render for that specific param and persists the full result regardless of whether the page ended up calling notFound() or rendering successfully. from the routers perspective, a 404 response for that param IS the resolved result, so it caches it just like any other. this means there is no way from inside the page component to tell the router not to persist the result. notFound() is a rendering outcome, but the persistence decision was already made at the routing layer before your component even ran. your middleware approach is the right call. one optimization we found useful on top of the Set lookup is adding a fast regex pre-filter before hitting the Set. most bot traffic follows obvious patterns like slugs with file extensions, path traversals, or known scanner prefixes. filtering those with a cheap regex before doing the Set.has() lookup cut our middleware response time since the regex handles the bulk of bot junk without touching the allowlist. for the dynamicParams equivalent under Cache Components, sounds like the team is working on something based on icyJosephs reply. until then middleware gating is the way to go. |
|
Good news — this is a genuinely well-researched question, and I can confirm your diagnosis is accurate based on what's actually documented, plus point you to the real answer. Your read on the mechanism is correct, and it's confirmed by Next.js's own Cache Components docs on ISR: once an unknown param resolves (App Shell → background upgrade), subsequent visits to that URL get served the upgraded result straight from cache, bypassing the App Shell entirely. Nothing in that doc distinguishes a successful render from a notFound() result — both are "the resolved outcome for this param," and both get persisted. So use cache only ever governed your data fetch; the page-level ISR/App-Shell-upgrade cache is a separate layer that persists per unique param regardless of what the render produced. You didn't miss an API — it currently doesn't exist. On your two direct questions: There's no supported way to prevent a specific param's notFound() render from being persisted by the App-Shell upgrade, short of stopping it from reaching the render pipeline at all (which is what your Middleware allowlist does). So to answer directly: yes, Middleware-based gating is currently the correct interim pattern — you're not missing a supported alternative, you've essentially hand-rolled the interceptor.ts concept ahead of its release. One refinement that might reduce your allowlist maintenance burden: since most of your bot traffic is probably hitting obviously-invalid patterns (.php, .env, .sql, encoded garbage) rather than almost-valid slugs, you could pair a cheap regex/pattern reject in Middleware with your allowlist, so the allowlist only has to arbitrate the smaller set of plausible-looking slugs. Given you've already got a clean repro and a well-articulated case, it's worth adding a comment to #84991 describing this bot-scanning scenario specifically — unbounded cache growth from adversarial traffic is a sharper motivating example than the i18n locale case that thread currently centers on, and could help push the interceptor API up the priority list. |
|
I'd suggest the following approach:
If none of these work, providing a minimal reproduction repo would help the maintainers diagnose the issue faster. |
|
One practical way to make the current workaround safer is to move the validation into proxy.ts and avoid relying on a process-local Set. For example, you can cheaply reject obvious scanner traffic first, then only call your authoritative lookup for plausible slugs: import { NextRequest, NextResponse } from 'next/server' const INVALID_PATTERNS = [ export async function proxy(request: NextRequest) { if (!match) { const slug = decodeURIComponent(match[1]) if (INVALID_PATTERNS.some((pattern) => pattern.test(slug))) { const response = await fetch( if (!response.ok) { return NextResponse.next() export const config = { I would prefer this over a mutable module-level allowlist because proxy.ts should not depend on process-local state staying synchronized across restarts or multiple workers. This still isn't as nice as having a Cache Components equivalent of dynamicParams = false, but it prevents invalid bot-generated params from entering the App Shell/ISR path at all while preserving on-demand caching for valid slugs. Based on the maintainer reply above, it sounds like this is the best interim option until the new control API lands. |
|
One issue with this example: !response.ok also catches 401, 429 and 5xx responses. Returning 404 for all of those would make existing songs appear missing during an API outage. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Title: notFound() results for bot-scanned invalid dynamic params get permanently cached, bloating disk — tried Cache Components, issue persists
Environment
next build && next start, single Node.js process (PM2, fork mode)app/[slug]/page.jsx, ~2,200 legitimate song pages (amusic streaming site), with the catalog growing continuously
The original problem
We had a straightforward dynamic route with the classic static-generation
model:
We deliberately did not use
generateStaticParamswith the full list of~2,200+ slugs, because the catalog grows continuously and pre-rendering the
entire set at build time would add substantial and ever-increasing build
time. Instead we relied on
dynamic = 'force-static'with the defaultdynamicParams: truebehavior — i.e. an ISR/fallback: blocking-stylepattern: new/unlisted slugs get rendered and cached on their first real
request.
What we discovered
Any public-facing dynamic route without a fixed, enumerable set of valid
params inevitably gets hit by bots/scanners probing for things like
wp-login.php,.env,backup.sql, or simply malformed/garbled slugs(URL-encoded garbage, etc.). Every one of these is a unique param value that
resolves to
notFound().We found that Next.js persisted a cache entry (
.html/.meta/.rscunder
.next/server/app) for every one of these invalid, bot-generatedslugs, exactly as if they were successful pages. Since bots generate an
effectively unbounded stream of unique garbage URLs, our
.nextcache grewto ~2GB for a site that actually only has ~2,200 real pages, and kept
growing indefinitely with no natural ceiling.
Approaches we considered before touching caching config, and why we
didn't use them
generateStaticParamsreturning the full list of valid slugs, withdynamicParams: false. Rejected for the reason above (unbounded,continuously growing build-time cost as the catalog grows).
dynamic = 'force-dynamic'/ opting the whole route out of staticrendering. Rejected because it discards instant, cached delivery (and
the associated performance/SEO benefits) for the ~2,200 legitimate
pages, which is the entire reason we wanted static/ISR-style rendering
in the first place. We only want to avoid persisting invalid params,
not give up caching for valid ones.
Redirecting invalid slugs to a canonical
/404page instead ofcalling
notFound(). Considered, but rejected because (a) it doesn'tavoid the underlying issue — a unique cache entry still gets generated
per invalid param, just for a redirect instead of an HTML page — and
(b) it produces a soft-404 (redirect + 200 on the destination) instead
of a proper 404 status, which is worse for SEO than the current
behavior.
Relying on
revalidateas a ceiling on how long a bad entrysurvives. This doesn't prevent the initial write and doesn't stop
unbounded growth from a continuous stream of unique bot-generated
slugs — each is a distinct param, so revalidation just refreshes each
one individually rather than reducing their count.
We found this described as a known, long-standing behavior here:
#63483 (closed as not planned).
We then migrated to Cache Components, expecting it to solve this
Based on the mental model that "
use cacheis opt-in, everything else isdynamic by default," we migrated the route to
cacheComponents: true,moving the data fetch into an explicitly cached function and calling
notFound()outside of it:Our expectation was that since
notFound()runs outside any'use cache'scope, it would never be persisted — only the successful
getSong()resultwould be.
This did not solve the problem, and we found out why
For a slug that doesn't exist, the documented behavior for unlisted dynamic
params applies regardless of the render's outcome:
Empirically, for an invalid slug we still see:
.html/.meta/.rscfiles written under.next/server/app.304 Not Modified,confirming the not-found render itself was cached as the "upgraded
result," not just the underlying data fetch.
So
'use cache'only governs the data cache (thegetSongfetch) — itdoes not govern whether the page-level render for a given dynamic param
gets persisted by the ISR/App-Shell-upgrade mechanism, which persists per
unique param regardless of success or
notFound(). In other words, CacheComponents changed how the data is cached, but did not change the
underlying per-param persistence behavior that caused our original problem.
We also found that an equivalent to
dynamicParams: falseforcacheComponentsis explicitly requested but doesn't currently exist(
dynamicParamsitself is rejected at build time whencacheComponentsisenabled):
#84991
Our current workaround
We're gating requests in
middleware.js, checking the requested slugagainst an in-memory allowlist (a
Setpopulated from our backend andrefreshed on song create/delete) before the request ever reaches the
route's render pipeline, so invalid params never get a chance to enter the
App-Shell-upgrade/caching path at all. This works, but it means
reimplementing, entirely outside the framework, exactly the kind of
validation that
dynamicParams: falseused to give us for free beforeCache Components.
Question for the team
to prevent a specific dynamic param's resolved render — specifically one
that ends in
notFound()— from being persisted by the App-Shellbackground-upgrade mechanism, without maintaining an external allowlist
in Middleware?
dynamicParams-equivalent requested in discussion `dynamicParams = false` for Cache Components & non-inheriting behavior #84991 on theroadmap? If so, is Middleware-based gating considered the interim
recommended pattern until then, or is there a different idiom we should
be using (e.g. something along the lines of the
interceptor.tssketchin that discussion)?
Happy to provide a minimal reproduction repo if useful.
Additional information
No response
Example
No response
All reactions