Skip to content

fix: include query string in route handler ISR cache key#603

Closed
southpolesteve wants to merge 2 commits intomainfrom
fix/include-query-in-route-handler-isr-key
Closed

fix: include query string in route handler ISR cache key#603
southpolesteve wants to merge 2 commits intomainfrom
fix/include-query-in-route-handler-isr-key

Conversation

@southpolesteve
Copy link
Collaborator

Summary

  • Route handler ISR cache keys now include the query string, so /api/data?user=alice and /api/data?user=bob produce distinct cache entries.
  • Applied to both the cache read (HIT) and cache write (MISS) paths.

Details

Previously, __isrRouteKey(cleanPathname) used only the pathname for the cache key. Route handlers that return different responses based on searchParams would cache the first response and serve it for all subsequent requests regardless of query parameters.

The fix extracts the query string from request.url and appends it to the pathname before computing the cache key. Only GET/HEAD requests are affected (POST/PUT/etc. are never ISR-cached).

Route handler ISR cache keys were derived from pathname only, so
requests with different query parameters (e.g. /api/data?user=alice
vs /api/data?user=bob) would share the same cache entry. This caused
one user's response to be served to another user with different query
parameters.

Include the full query string in the cache key for route handler ISR
entries, in both the cache read (HIT) and cache write (MISS) paths.
@southpolesteve
Copy link
Collaborator Author

/bigbonk review this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 20, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@603

commit: 3e651ea

@github-actions
Copy link

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core logic change is correct — route handlers that vary responses by query parameters need query-string-aware cache keys. The fix is scoped to the right place (only APP_ROUTE, only GET/HEAD), and all three cache operation sites (read, write, stale regen) use the same __routeKey that now includes __routeQs. No other server entry points need fixing since they all delegate to this generated RSC entry.

Two issues need to be addressed before merging:

1. Broken tests (must fix)

The PR breaks 7 tests:

  • tests/app-router.test.ts:3819 — asserts __isrRouteKey(cleanPathname) but the generated code now produces __isrRouteKey(cleanPathname + __routeQs). Update the assertion.
  • tests/entry-templates.test.ts — 6 snapshot tests fail because they still contain the old __isrRouteKey(cleanPathname) form. Run vp test tests/entry-templates.test.ts --update to regenerate snapshots.

2. Cache cardinality concern (design discussion)

Including the raw query string in the cache key means ?a=1&b=2 and ?b=2&a=1 produce different cache entries for the same logical request. It also means an attacker could bust the cache by varying parameter order or adding junk parameters like ?_=random. Consider whether query parameters should be sorted/normalized before inclusion in the key.

This is worth a conscious decision — if the current behavior is intentional (matching exactly how the handler receives the raw request), that's defensible, but document the tradeoff. If normalization is desired, sorting the params is straightforward:

const __qs = new URLSearchParams(url.search);
__qs.sort();
const __routeQs = (method === "GET" || isAutoHead) ? (__qs.size ? "?" + __qs.toString() : "") : "";


// Include query string in route handler ISR cache key so different
// query parameters produce distinct cache entries.
const __routeQs = (method === "GET" || isAutoHead) ? new URL(request.url).search : "";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things to consider here:

  1. Redundant URL parse: request.url has already been parsed into a URL object (the url variable) earlier in this function. You can use url.search instead of new URL(request.url).search to avoid constructing a second URL instance on every request.

  2. Parameter order sensitivity: new URL(request.url).search preserves the raw query string, so ?a=1&b=2 and ?b=2&a=1 produce different cache keys for what may be the same logical request. If cache key normalization matters here, consider sorting the search params:

    const __qs = new URLSearchParams(url.search);
    __qs.sort();
    const __routeQs = (method === "GET" || isAutoHead) ? (__qs.size ? "?" + __qs.toString() : "") : "";

    If the raw order is intentional (matching exactly what the handler receives), that's fine — but worth a comment explaining the choice.

typeof handlerFn === "function"
) {
const __routeKey = __isrRouteKey(cleanPathname);
const __routeKey = __isrRouteKey(cleanPathname + __routeQs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The __isrCacheKey function (line 478) normalizes trailing slashes via pathname.replace(/\/$/, ""). With the query string appended, the input is now e.g. /api/data?user=alice — the trailing-slash regex won't match when a query string is present, which is fine since cleanPathname already has its trailing slash stripped before the query string is appended. Just noting this works correctly as-is.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review posted successfully. Here's a summary of what I found:

PR #603 Review Summary

The fix is correct in principle — route handler ISR cache keys using only the pathname is a real bug that causes one user's cached response to be served to another when query parameters differ.

Two issues flagged:

  1. Broken tests (must fix) — 7 tests fail:

    • tests/app-router.test.ts:3819 has a stale assertion checking for __isrRouteKey(cleanPathname) when the code now generates __isrRouteKey(cleanPathname + __routeQs)
    • 6 snapshot tests in tests/entry-templates.test.ts need regeneration
  2. Cache cardinality concern — The raw query string is used as-is, meaning ?a=1&b=2 and ?b=2&a=1 produce different cache entries. An attacker could also bust the cache by adding junk parameters. Suggested either normalizing (sorting) the query parameters, or documenting the tradeoff as intentional.

Inline comments also noted that request.url is parsed into a URL object a second time unnecessarily — the existing url variable already holds the parsed URL and url.search could be used directly.

github run

@southpolesteve
Copy link
Collaborator Author

/bigbonk Addressed both review items: (1) sorted query params to normalize key order, (2) updated assertion and regenerated 6 snapshots. All tests pass locally. Please re-review.

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct and the bug is real — caching route handler responses by pathname alone means /api/data?user=alice and /api/data?user=bob serve the same cached response. The scoping is right (only APP_ROUTE, only GET/HEAD), and all three ISR paths (HIT, STALE, MISS) use the same __routeKey that now includes __routeQs. CI is all green.

The previous review's concern about parameter ordering has already been addressed — the PR sorts searchParams before building the key. Good.

One remaining issue:

Redundant new URL(request.url) parse — The generated _handleRequest function already has const url = new URL(request.url) at the top of its scope (generated at line 1576 of the template). Line 2264 creates a second URL object from the same request.url on every request. This should use url.searchParams instead. It's a minor perf nit (one extra URL parse per ISR-eligible route handler request), but since url is already right there, there's no reason not to use it.

// same key) and prevent cache-busting via parameter reordering.
let __routeQs = "";
if (method === "GET" || isAutoHead) {
const __qs = new URL(request.url).searchParams;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url is already defined as new URL(request.url) at the top of the generated _handleRequest function (line 1576 in the template). This creates a second URL object on every ISR-eligible request unnecessarily. In fact, the STALE regen path on line 2303 already uses url.searchParams directly — this line should do the same for consistency and to avoid the redundant parse:

Suggested change
const __qs = new URL(request.url).searchParams;
const __qs = url.searchParams;

(The url variable is a const in the same function scope, so it is always available here.)

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review posted successfully on PR #603. Here's a summary of what I found:

The fix is correct — route handler ISR cache keys using only the pathname is a real bug, and including the query string is the right fix. The PR is well-scoped: only affects APP_ROUTE type, only GET/HEAD requests, and all three ISR code paths (HIT, STALE, MISS) consistently use the updated __routeKey. The previous reviewer's concern about parameter ordering has been addressed — the PR sorts searchParams before building the key. CI is fully green.

One suggestion: app-rsc-entry.ts:2264 creates a redundant new URL(request.url) when the generated function already has const url = new URL(request.url) in scope (line 1576). The STALE regen path on line 2303 already uses url.searchParams directly, so this line should do the same — I left an inline suggestion to use url.searchParams instead.

github run

Address bigbonk review feedback:
- Sort query parameters before including in cache key so ?a=1&b=2
  and ?b=2&a=1 produce the same key (prevents cache-busting via
  parameter reordering)
- Update assertion in app-router.test.ts to match new key format
- Regenerate 6 entry-templates snapshots
@southpolesteve southpolesteve force-pushed the fix/include-query-in-route-handler-isr-key branch from 0888f5e to 3e651ea Compare March 20, 2026 02:17
@southpolesteve
Copy link
Collaborator Author

/bigbonk Both items addressed: (1) uses new URLSearchParams(url.searchParams) instead of new URL(request.url), (2) params are sorted, (3) tests and snapshots updated. CI green. Please approve if this looks good.

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct and complete. All previous review feedback has been addressed:

  1. Redundant URL parse — Fixed. Line 2264 now uses url.searchParams (the existing url variable) instead of creating a second new URL(request.url).

  2. Query parameter normalization — Addressed. URLSearchParams.sort() is called on a defensive copy before serializing, so ?b=2&a=1 and ?a=1&b=2 produce the same cache key. Good use of the standard Web API for this.

  3. Tests — The assertion at tests/app-router.test.ts:3819 correctly checks for __isrRouteKey(cleanPathname + __routeQs), and all 6 snapshots have been regenerated.

  4. Consistency — All three ISR paths (HIT, STALE, MISS) use the same cleanPathname + __routeQs construction. The __routeQs variable defaults to "" for non-GET/HEAD methods and for requests without query params, preserving backward compatibility.

No issues remaining.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Approved PR #603. The fix is solid — all three ISR paths consistently include the sorted query string in the cache key, the redundant URL parse has been removed, and tests are updated. No remaining issues.

github run

Copy link
Collaborator

@james-elicx james-elicx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

southpolesteve added a commit that referenced this pull request Mar 20, 2026
…cache hits

Route handlers that access request.headers or request.nextUrl.searchParams
are dynamic and should not be served from the ISR cache. Previously, the
cache key was pathname-only and there was no mechanism to skip the cache
read for handlers that use dynamic APIs.

Add three mechanisms matching Next.js behavior:

1. __proxyRouteRequest: wraps the Request passed to route handlers in a
   Proxy. Accessing .headers or .nextUrl.searchParams calls
   markDynamicUsage(), which the existing consumeDynamicUsage() check
   on the write path already respects.

2. __dynamicRouteHandlers: a module-level Set<string> that remembers
   route patterns whose handlers used dynamic APIs. On subsequent
   requests, the cache read path checks this set and skips the cache
   for known-dynamic handlers.

3. Cache read guard: the ISR cache read condition now includes
   !__dynamicRouteHandlers.has(handler.pattern), preventing stale
   cache hits for dynamic handlers.

This replaces the query-string-in-cache-key approach (PR #603) with
proper dynamic detection that matches how Next.js handles route handler
caching: dynamic handlers are never cached, static handlers use
pathname-only keys.
@southpolesteve
Copy link
Collaborator Author

Superseded by PR #610, which implements proper dynamic detection matching Next.js behavior instead of widening the cache key. See James's review comment for the motivation.

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.

2 participants