Skip to content

fix(middleware-runtime): normalize trailing slash on plain-URL redirect locations - #1750

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
Divkix:fix/trailing-slash-middleware
Jun 5, 2026
Merged

fix(middleware-runtime): normalize trailing slash on plain-URL redirect locations#1750
james-elicx merged 5 commits into
cloudflare:mainfrom
Divkix:fix/trailing-slash-middleware

Conversation

@Divkix

@Divkix Divkix commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Description

When middleware redirects via a plain new URL('/x', req.url) instead of request.nextUrl, the Location header bypassed NextURL._applyTrailingSlash() and was emitted verbatim — ignoring the user's trailingSlash config.

Root cause: In executeMiddleware, relativizeLocation() extracts the redirect Location but there was no trailing-slash normalization step for plain-URL redirects. NextURL-based redirects already work correctly because NextURL applies the policy at stringify time.

Fix: After relativizeLocation(), apply normalizeTrailingSlashPathname() to same-origin redirect Location pathnames when trailingSlash is configured. File-extension paths (e.g. /file.css) are correctly exempted by the existing FILE_LIKE_PATHNAME_RE guard inside normalizeTrailingSlashPathname.

Related Issue

Closes #1332

Potential Risk & Impact

  • Change is scoped to the redirect branch of executeMiddleware and only fires when trailingSlash is explicitly set in config.
  • Cross-origin redirect Location headers are untouched (the same-origin guard ensures that).
  • Malformed Location values fall through unchanged via the try/catch.
  • Dev and prod paths both go through executeMiddleware, so parity is maintained without changes to prod-server.ts.

How Has This Been Tested?

  • Added 3 new unit tests to tests/middleware-runtime-trailing-slash.test.ts:
    • Plain URL redirect gets trailing slash added (trailingSlash: true)
    • Plain URL redirect gets trailing slash removed (trailingSlash: false)
    • Plain URL redirect to file path is not normalized (/file.css stays /file.css)
  • All 7 tests in middleware-runtime-trailing-slash.test.ts pass
  • All 6 tests in trailing-slash.test.ts pass
  • vp check (format + lint + typecheck) passes clean

…ct locations

When middleware does NextResponse.redirect(new URL('/x', req.url)), the
Location header bypasses NextURL._applyTrailingSlash() and was emitted
verbatim even when trailingSlash config required normalization.

After relativizeLocation(), apply normalizeTrailingSlashPathname() to the
pathname of same-origin redirect Locations when trailingSlash is configured.
File-extension paths (e.g. /file.css) are correctly exempted by the existing
FILE_LIKE_PATHNAME_RE guard inside normalizeTrailingSlashPathname.

Adds 3 new tests covering the plain-URL redirect gap (trailingSlash: true,
trailingSlash: false, file-extension exemption).

Fixes cloudflare#1332
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 3daf5f2

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling #1332. The high-level direction is right: Next.js does apply its trailing-slash policy to middleware redirect Location headers. In packages/next/src/server/web/adapter.ts the redirect is re-wrapped in a NextURL before being relativized:

const redirectURL = new NextURL(redirect, { forceLocale: false, headers, nextConfig })
// ...
if (redirectURL.host === requestURL.host) {
  response.headers.set('Location', getRelativeURL(redirectURL, requestURL))
}

So normalizing plain-new URL() redirect Locations is the correct goal, and the same-origin guard matches Next.js's redirectURL.host === requestURL.host check.

However, this PR uses the wrong normalization function, and the result diverges from Next.js.

NextURL applies trailing-slash via Next.js's formatNextPathnameInfo (packages/next/src/shared/lib/router/utils/format-next-pathname-info.ts):

return !info.buildId && info.trailingSlash
  ? !pathname.endsWith('/') ? addPathSuffix(pathname, '/') : pathname
  : removeTrailingSlash(pathname)

That logic is a plain add-or-strip — it does not special-case /api, /.well-known, or file-like paths. vinext's own NextURL._applyTrailingSlash shim (packages/vinext/src/shims/server.ts:373) matches this exactly.

But normalizeTrailingSlashPathname (the function this PR reuses) is the request-side filesystem-routing normalizer. Per its own docstring it special-cases /api, /.well-known, .rsc, and file-like paths, returning null (no change) for them. Applying it to redirect Locations produces behavior that:

  1. Diverges from Next.js. A trailingSlash: true redirect to /file.css should become /file.css/ (NextURL/formatNextPathnameInfo just appends /). This PR keeps it as /file.css. The new test "plain URL redirect to file path is not normalized" is asserting incorrect behavior.
  2. Is inconsistent within vinext. A NextResponse.redirect(request.nextUrl) to /file.css already goes through the shim's _applyTrailingSlash and yields /file.css/, while a NextResponse.redirect(new URL('/file.css', req.url)) now yields /file.css. Same intent, two different results.

Suggested fix: normalize redirect Locations using the same rule NextURL/formatNextPathnameInfo uses (plain add-or-strip, only exempting root), not normalizeTrailingSlashPathname. This both matches Next.js and keeps NextURL-based and plain-URL redirects consistent. Consider extracting/sharing the _applyTrailingSlash logic so the shim and middleware-runtime stay in lockstep.

Secondary: normalizeTrailingSlashPathname is documented to take a basePath-stripped pathname, but loc.pathname here still includes basePath, so its /api check can misfire under a configured basePath. Switching to the formatNextPathnameInfo-equivalent rule sidesteps this too.

try {
const loc = new URL(relativeLocation, options.request.url);
if (loc.origin === new URL(options.request.url).origin) {
const normalized = normalizeTrailingSlashPathname(loc.pathname, options.trailingSlash);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the core issue. normalizeTrailingSlashPathname is the request-side filesystem normalizer — it special-cases /api, /.well-known, .rsc, and file-like paths (returns null for them). Next.js's middleware redirect path instead uses formatNextPathnameInfo via NextURL, which is a plain add/strip with no such exemptions.

Result: a trailingSlash: true redirect to /file.css should become /file.css/ (matching Next.js + vinext's own NextURL._applyTrailingSlash shim at shims/server.ts:373), but this code leaves it as /file.css. That diverges from Next.js and is inconsistent with NextURL-based redirects in vinext.

Normalize with the same rule the shim uses instead. A minimal inline version (ideally share the shim's _applyTrailingSlash):

Suggested change
const normalized = normalizeTrailingSlashPathname(loc.pathname, options.trailingSlash);
const p = loc.pathname;
let normalized: string | null = null;
if (p !== "" && p !== "/") {
if (options.trailingSlash) {
normalized = p.endsWith("/") ? null : p + "/";
} else {
normalized = p.endsWith("/") ? p.slice(0, -1) : null;
}
}

});

expect(result.continue).toBe(false);
expect(result.redirectUrl).not.toBeUndefined();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test enshrines behavior that diverges from Next.js. NextURL applies trailing-slash via formatNextPathnameInfo, which appends / unconditionally for non-root paths (no file-extension exemption). So with trailingSlash: true, Next.js — and vinext's own NextURL._applyTrailingSlash for a request.nextUrl redirect — would emit /file.css/, not /file.css.

After switching to the formatNextPathnameInfo-equivalent rule, this expectation should be /file.css/. Worth adding a parallel test showing a NextURL-based redirect to /file.css produces the same result, to lock in consistency between the two redirect paths.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Here's a summary of my findings:

Review Summary: Changes Requested

The PR's goal is correct — Next.js does apply its trailing-slash policy to middleware redirect Location headers (verified in adapter.ts, which re-wraps redirects in NextURL). The same-origin guard correctly mirrors Next.js's redirectURL.host === requestURL.host check.

The blocking issue: wrong normalization function.

The PR reuses normalizeTrailingSlashPathname, which is the request-side filesystem-routing normalizer. It special-cases /api, /.well-known, .rsc, and file-like paths (returns null/no-change for them).

But Next.js's middleware redirect path uses formatNextPathnameInfo (via NextURL), which is a plain add-or-strip with no such exemptions. vinext's own NextURL._applyTrailingSlash shim matches this.

This creates two problems:

  1. Divergence from Next.js — a trailingSlash: true redirect to /file.css should become /file.css/, but this PR keeps it /file.css. The new "file path is not normalized" test asserts incorrect behavior.
  2. Internal inconsistency — a NextResponse.redirect(request.nextUrl) to /file.css yields /file.css/ (via shim), while NextResponse.redirect(new URL('/file.css', req.url)) now yields /file.css. Same intent, different results.

Recommended fix: Use the same plain add/strip rule as _applyTrailingSlash/formatNextPathnameInfo (only exempt root), ideally by sharing the shim logic. I posted a suggestion with a minimal inline version.

I also flagged a secondary basePath concern (normalizeTrailingSlashPathname expects a basePath-stripped pathname, but receives the full loc.pathname), which the recommended fix also resolves.

github run

…ailing slash

Replaces normalizeTrailingSlashPathname (the filesystem-routing normalizer,
which exempts /api, file-extension paths, and /.well-known) with the same
plain add/strip rule used by NextURL._applyTrailingSlash and Next.js's
formatNextPathnameInfo: only the root path is exempt.

This makes plain-new-URL() redirects consistent with NextURL-based redirects
and matches Next.js behavior, where a trailingSlash: true redirect to /file.css
produces /file.css/ rather than keeping the path unchanged.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review: Looks good

The previous ask-bonk blocking concerns have been addressed. The implementation now uses the plain add/strip rule matching Next.js's formatNextPathnameInfo (and vinext's own NextURL._applyTrailingSlash shim at shims/server.ts:373-379) instead of the request-side normalizeTrailingSlashPathname. I verified each prior concern:

  1. Divergence from Next.js (file paths) — Fixed. The inline logic at middleware-runtime.ts:303-314 only exempts root (""/"/") and otherwise does an unconditional add/strip. A trailingSlash: true redirect to /file.css now correctly yields /file.css/. The test at line 152 was updated to assert /file.css/.

  2. Internal consistency — Fixed and now locked in. The new "NextURL redirect to file path ... (same result as plain URL)" test confirms both the request.nextUrl path (via the shim) and the plain-new URL() path produce identical /file.css/ output. The inline rule is byte-for-byte equivalent to _applyTrailingSlash.

  3. basePath concern — Resolved. Since normalizeTrailingSlashPathname (with its basePath-sensitive /api check) is no longer used, a configured basePath no longer causes misfires. The plain rule operates on the full path, matching formatNextPathnameInfo.

Other checks I performed:

  • Cross-origin redirects are untouched: the loc.origin === new URL(options.request.url).origin guard correctly skips absolute cross-origin Locations returned by relativizeLocation.
  • Double-normalization is safe/idempotent: NextURL-based redirects already carry the slash, so p.endsWith("/") short-circuits to null (no change).
  • Malformed URLs fall through unchanged via the try/catch.
  • All 8 tests in middleware-runtime-trailing-slash.test.ts pass; vp check is clean on both changed files.

One non-blocking nit below.

Minor pre-existing note: the NextURL.trailingSlash docstring in shims/server.ts:301 says NextURL skips "non-file-like" pathnames, but _applyTrailingSlash actually has no file-like exemption — the comment is stale (not introduced by this PR, but worth a quick fix while nearby).

let normalizedLocation = relativeLocation;
if (options.trailingSlash !== undefined) {
try {
const loc = new URL(relativeLocation, options.request.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit (non-blocking): new URL(options.request.url) is parsed twice — once implicitly as the base for relativeLocation on line 298 and again on line 299 for the origin check. Minor, but you could hoist a single const reqUrl = new URL(options.request.url) and reuse it for both the base and the origin comparison.

Also consider extracting the inline add/strip into a small shared helper so it stays in lockstep with NextURL._applyTrailingSlash (shims/server.ts:373). They're currently identical by hand; a shared function would prevent future drift.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully (APPROVED). Here's a summary of my re-review of PR #1750:

Re-review Summary: Approved

The PR was updated since the prior ask-bonk review, and all three blocking concerns from that review are now resolved.

What changed: The implementation no longer uses normalizeTrailingSlashPathname (the request-side filesystem normalizer with /api/file-like exemptions). It now uses an inline plain add/strip rule at middleware-runtime.ts:303-314 that is byte-for-byte equivalent to vinext's own NextURL._applyTrailingSlash shim (shims/server.ts:373-379) and matches Next.js's formatNextPathnameInfo.

Verified concerns:

  1. Next.js divergence (file paths) — Fixed. /file.css/file.css/ with trailingSlash: true. The test was corrected to assert this.
  2. Internal consistency — Fixed and locked in by a new test confirming request.nextUrl and plain-new URL() redirects produce identical output.
  3. basePath misfire — Resolved, since the problematic normalizer is gone.

Additional checks I performed (all pass):

  • Cross-origin redirects untouched (origin guard works correctly).
  • Double-normalization is idempotent for NextURL-based redirects.
  • Malformed URLs fall through via try/catch.
  • All 8 tests pass; vp check clean on both files.

Non-blocking nits posted: a minor double-parse of new URL(options.request.url), a suggestion to extract the add/strip into a shared helper (to prevent drift from the shim), and a note about a stale "non-file-like" docstring in shims/server.ts:301.

github run

@james-elicx
james-elicx merged commit 3873545 into cloudflare:main Jun 5, 2026
30 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 5, 2026
@Divkix
Divkix deleted the fix/trailing-slash-middleware branch June 5, 2026 22:32
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.

Trailing slash configuration not enforced

2 participants