Skip to content

fix(app-router): apply trailingSlash to route handler request URL (#1827)#1835

Merged
james-elicx merged 2 commits into
mainfrom
fix/issue-1827-route-handler-trailing-slash
Jun 8, 2026
Merged

fix(app-router): apply trailingSlash to route handler request URL (#1827)#1835
james-elicx merged 2 commits into
mainfrom
fix/issue-1827-route-handler-trailing-slash

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Problem

The trailingSlash config was not applied to App Router route handlers (app/**/route.ts). A request to /runtime/edge/ with trailingSlash: true surfaced req.url without the trailing slash, even though req.nextUrl.pathname correctly retained it.

This caused the upstream suite test/e2e/app-dir/app-routes-trailing-slash (edge + node runtime variants) to fail: the handler reads new URL(req.url).pathname and expects /runtime/edge/.

Root cause

Route-handler dispatch builds the handler's NextRequest via createTrackedAppRouteRequest -> buildNextConfig. buildNextConfig threaded basePath and i18n into the NextRequest's NextURL, but omitted trailingSlash. With trailingSlash unset, NextURL defaulted to false and NextURL.toString() (used to compute NextRequest.url) stripped the trailing slash from req.url.

Note: the 308 redirect itself already worked (via the central normalizeTrailingSlash); only the served request's req.url was wrong.

Fix

Thread the configured trailingSlash through the route-handler dispatch chain so NextURL applies the correct policy when computing request.url:

app-rsc-entry (codegen) -> dispatchAppRouteHandler -> executeAppRouteHandler / cache revalidation -> runAppRouteHandler -> createTrackedAppRouteRequest -> buildNextConfig -> NextRequest/NextURL.

This mirrors the existing i18n threading exactly. When trailingSlash is false (default) and no basePath/i18n is set, buildNextConfig still returns null as before, so the common case is unchanged.

The codegen entry (app-rsc-entry.ts) is shared by dev, prod, and Cloudflare Workers runtimes, so all three are covered by the single change.

Test

Added tests/app-route-handler-trailing-slash.test.ts (mirrors the upstream test) plus fixture route handlers tests/fixtures/app-basic/app/runtime/{edge,node}/route.ts. Covers:

  • trailingSlash: true: /runtime/{edge,node} -> 308 -> /runtime/{edge,node}/, then 200 with the handler observing the slashed url and nextUrl.
  • trailingSlash: false: /runtime/edge/ -> 308 -> /runtime/edge, then 200.

Confirmed the test fails before the fix and passes after. Existing request-pipeline, trailing-slash, and route-handler dispatch/execution/cache/runtime suites remain green.

Closes #1827

)

App Router route handlers received a NextRequest whose `url` was
normalized with the default `trailingSlash: false` policy, so a request
to `/runtime/edge/` (with `trailingSlash: true`) surfaced `req.url`
without the trailing slash even though `req.nextUrl.pathname` kept it.

Thread the configured `trailingSlash` through the route-handler dispatch
chain (dispatch -> execution/cache -> tracked request -> NextRequest)
so `NextURL` applies the correct policy when computing `request.url`,
matching Next.js (test/e2e/app-dir/app-routes-trailing-slash).

Closes #1827
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1835
npm i https://pkg.pr.new/vinext@1835

commit: f1e4090

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

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
workers-cache preview production
web preview production

Copy link
Copy Markdown
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.

Review: fix(app-router): apply trailingSlash to route handler request URL

Verdict: LGTM. The fix is correct, minimal, and complete. Verified by tracing the full dispatch chain and running the new + adjacent suites.

What I verified

Threading is complete and consistent. trailingSlash is wired through every hop and both branches of the dispatch:

  • app-rsc-entry.ts:753 passes __trailingSlash (already defined at app-rsc-entry.ts:516, used elsewhere) into dispatchAppRouteHandler.
  • dispatchAppRouteHandler forwards it on both paths: the cache/revalidation read at app-route-handler-dispatch.ts:202 and the direct execution path at :260.
  • The cache revalidation path forwards it into runAppRouteHandler (app-route-handler-cache.ts:116).
  • runAppRouteHandler passes it to createTrackedAppRouteRequest (app-route-handler-execution.ts:132).
  • buildNextConfig includes it and now returns non-null when only trailingSlash is set (app-route-handler-runtime.ts:123).

The downstream consumption is sound. nextConfig.trailingSlash flows into new NextRequest(..., { nextConfig })NextURL config (shims/server.ts:130) → this._url = this._nextUrl.toString() (shims/server.ts:134-136), which applies the trailing-slash policy to req.url. This is exactly the field the bug report identified as missing.

Single dispatch path → dev/prod/CF parity holds. createTrackedAppRouteRequest has exactly one call site, and route-handler dispatch flows only through the shared codegen entry (dispatchMatchedRouteHandler__dispatchAppRouteHandler). There is no separate prod-only or Pages-Router route-handler dispatch that needs the same fix, so the AGENTS.md dev/prod-parity concern does not apply here.

Common case unchanged. buildNextConfig still returns null when trailingSlash is false/unset and no basePath/i18n is configured, so existing behavior is preserved.

Checks run locally

  • vp test run tests/app-route-handler-trailing-slash.test.ts — 4 passed (both edge + node, true + false).
  • vp test run tests/app-router.test.ts tests/routing.test.ts — 112 + routing passed (new fixtures don't regress route scanning).
  • vp test run tests/app-router-next-config-codegen.test.ts tests/entry-templates.test.ts — 50 passed (codegen entry change is benign).
  • vp check on all 5 changed source files + the test — format/lint/types clean.

Minor (non-blocking)

  • Coverage asymmetry: the trailingSlash: true case uses it.each(["edge", "node"]), but the trailingSlash: false case only exercises edge. Adding node to the false case would make the matrix symmetric. Not required — it mirrors the upstream test shape.
  • Type optionality: trailingSlash?: boolean is threaded as optional throughout (matching the existing i18n pattern), which is fine. No action needed.

No correctness, edge-case, or parity issues found.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review posted to PR #1835. Summary of my findings:

Verdict: LGTM

The fix is correct, minimal, and complete. I traced the full dispatch chain and ran the relevant suites.

What's correct:

  • trailingSlash is threaded through every hop: app-rsc-entry.ts:753dispatchAppRouteHandler (both the cache-read path and direct-execution path) → runAppRouteHandlercreateTrackedAppRouteRequestbuildNextConfig.
  • Downstream consumption is sound: nextConfig.trailingSlash reaches NextURL (shims/server.ts:130) and is applied to req.url via this._nextUrl.toString() — exactly the field the bug report flagged.
  • buildNextConfig now returns non-null when only trailingSlash is set, but still returns null for the default case, so existing behavior is preserved.
  • Single dispatch path means dev/prod/CF parity holds — no separate prod-only route-handler path to keep in sync (the AGENTS.md parity concern doesn't apply here).

Checks run locally (all green):

  • New trailing-slash test: 4 passed
  • app-router + routing: passed (fixtures don't regress)
  • codegen + entry-templates: 50 passed
  • vp check on all changed files: format/lint/types clean

Minor non-blocking notes:

  • Coverage asymmetry: the true case tests both edge+node; the false case only tests edge.
  • No correctness, edge-case, or parity issues found.

github run

…ndler (#1827)

Address ask-bonk review note on PR #1835: the trailingSlash:false suite
only exercised the edge runtime. Parameterize it over edge + node to
match the trailingSlash:true suite's coverage.
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Copy link
Copy Markdown
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.

/bigbonk review — fix(app-router): apply trailingSlash to route handler request URL

Verdict: LGTM. Independently re-traced the dispatch chain, ran the suites, and checked for parity/edge-case gaps. The fix is correct, minimal, and complete.

Threading is complete on every hop and both branches

  • app-rsc-entry.ts:753 passes __trailingSlash into __dispatchAppRouteHandler via dispatchMatchedRouteHandler. __trailingSlash is the same value already defined at app-rsc-entry.ts:516 and used by the top-level handler config (:948), so no new state is introduced.
  • dispatchAppRouteHandler forwards it on both paths: the cache/revalidation read (app-route-handler-dispatch.ts:202) and direct execution (:260).
  • Cache path → runAppRouteHandler (app-route-handler-cache.ts:114); direct path → executeAppRouteHandler, which spreads ...options into runAppRouteHandler. Confirmed ExecuteAppRouteHandlerOptions = {...} & RunAppRouteHandlerOptions (app-route-handler-execution.ts:109), so trailingSlash rides the intersection — no silent drop.
  • runAppRouteHandlercreateTrackedAppRouteRequest (app-route-handler-execution.ts:132) → buildNextConfig.

Downstream consumption is the field the bug flagged

buildNextConfig now includes trailingSlashnew NextRequest(..., { nextConfig })NextURLConfig.nextConfig.trailingSlash (shims/server.ts:130) → this._url = this._nextUrl.toString() (shims/server.ts:134-136). That's exactly req.url, the value the report identified as missing.

Common case preserved

buildNextConfig still returns null when trailingSlash is falsy and no basePath/i18n is set (app-route-handler-runtime.ts:123), so the default path is unchanged.

Parity holds

createTrackedAppRouteRequest has one call site, and app-router route-handler dispatch flows only through the shared codegen entry — covering dev/prod/CF. Edge vs node route handlers share this dispatch (the runtime = "edge" segment config affects the x-edge-runtime header, not a separate dispatch fork), so one threading point covers both. No separate prod-only path needs the same fix.

No fixture regression

The new app/runtime/{edge,node}/route.ts are added to the shared app-basic fixture under a new runtime/ dir (no collision). No test asserts an exact total route count on app-basic; routing.test.ts re-ran green.

Checks run locally (all green)

  • tests/app-route-handler-trailing-slash.test.ts — 4 passed (edge + node × true + false)
  • tests/{app-route-handler-runtime,dispatch,execution,cache}.test.ts + tests/trailing-slash.test.ts — 36 passed
  • tests/routing.test.ts — 112 passed
  • vp check on all 5 source files + the test — format/lint/types clean

Note (resolved)

The earlier coverage-asymmetry note (false case only testing edge) was already addressed by the follow-up commit f1e4090 — both true and false now use it.each(["edge", "node"]). Matrix is symmetric.

No correctness, edge-case, or parity issues found.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review posted to PR #1835.

Verdict: LGTM

I independently re-traced the full dispatch chain, ran the suites, and checked for parity and edge-case gaps. The fix is correct, minimal, and complete.

What I verified:

  • trailingSlash is threaded through every hop and both dispatch branches (cache-read and direct-execution). The direct path relies on ExecuteAppRouteHandlerOptions & RunAppRouteHandlerOptions intersection + ...options spread — confirmed it doesn't silently drop.
  • Downstream consumption is sound: buildNextConfigNextRequest nextConfigNextURLreq.url (shims/server.ts:130,134-136), the exact field the bug flagged.
  • Common case preserved: buildNextConfig still returns null for the default (trailingSlash falsy, no basePath/i18n).
  • Parity holds: single createTrackedAppRouteRequest call site, shared codegen entry covers dev/prod/CF. Edge vs node route handlers share this dispatch, so one threading point covers both.
  • No fixture regression: new runtime/ dir, no name collision, no exact-route-count assertions on app-basic.

Checks run (all green): new trailing-slash test (4), the 4 route-handler unit suites + trailing-slash (36), routing (112), and vp check on all changed files.

One note (already resolved): the coverage-asymmetry the prior review flagged was fixed by follow-up commit f1e4090 — both true and false cases now test edge+node.

No correctness, edge-case, or parity issues found.

github run

@james-elicx james-elicx marked this pull request as ready for review June 8, 2026 12:56
@james-elicx james-elicx merged commit 97947ce into main Jun 8, 2026
51 checks passed
@james-elicx james-elicx deleted the fix/issue-1827-route-handler-trailing-slash branch June 8, 2026 18:02
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.

App Router: trailingSlash not applied to route handlers

1 participant