From d3dec315232fe5251f0fc180ab5b1b600b6c7dd4 Mon Sep 17 00:00:00 2001 From: Rizky Mirzaviandy Priambodo <142987522+Xavrir@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:18:01 +0700 Subject: [PATCH] fix: match bare basePath root for catch-all middleware matchers When `basePath` is set, `getMiddlewareMatchers` prepends it to each matcher source. For a catch-all matcher like `/((?!api).*)` that previously matched the site root `/`, the prepended form becomes anchored at `${basePath}/...`, so the bare `basePath` root (e.g. `/docs`) no longer matches even though `/` did without a basePath. Middleware therefore did not run on the basePath root. Detect when a matcher matches `/` before prepending basePath, and in that case also accept the bare basePath root by OR-ing in an anchored `^${basePath}[/#?]?$` alternative. Specific and param matchers are unaffected. Adds regression tests covering catch-all, static, and param matchers. Closes #73786 --- .../analysis/get-page-static-info.test.ts | 46 +++++++++++++++++++ .../build/analysis/get-page-static-info.ts | 31 +++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/next/src/build/analysis/get-page-static-info.test.ts b/packages/next/src/build/analysis/get-page-static-info.test.ts index e6a870e0cc83..60524884d0d5 100644 --- a/packages/next/src/build/analysis/get-page-static-info.test.ts +++ b/packages/next/src/build/analysis/get-page-static-info.test.ts @@ -86,5 +86,51 @@ describe('get-page-static-infos', () => { regex.test('/index.segments/$c$children/__PAGE__.segment.rsc') ).toBe(true) }) + + it('matches the bare basePath root for a catch-all matcher', () => { + // Regression test for https://github.com/vercel/next.js/issues/73786 + const matcher = '/((?!api|_next/static|_next/image|favicon.ico).*)' + const regex = new RegExp( + getMiddlewareMatchers(matcher, { + i18n: undefined, + basePath: '/docs', + })[0].regexp + ) + + // The bare basePath root must match (previously it did not). + expect(regex.test('/docs')).toBe(true) + expect(regex.test('/docs/')).toBe(true) + expect(regex.test('/docs/foo')).toBe(true) + // Negative lookahead and word boundaries must still hold. + expect(regex.test('/docs/api/x')).toBe(false) + expect(regex.test('/docsfoo')).toBe(false) + expect(regex.test('/foo')).toBe(false) + }) + + it('does not match the bare basePath root for a specific matcher', () => { + const regex = new RegExp( + getMiddlewareMatchers('/dashboard', { + i18n: undefined, + basePath: '/docs', + })[0].regexp + ) + + expect(regex.test('/docs/dashboard')).toBe(true) + // A non-root matcher should not be widened to match the basePath root. + expect(regex.test('/docs')).toBe(false) + expect(regex.test('/docs/')).toBe(false) + }) + + it('does not match the bare basePath root for a param matcher', () => { + const regex = new RegExp( + getMiddlewareMatchers('/:id', { + i18n: undefined, + basePath: '/docs', + })[0].regexp + ) + + expect(regex.test('/docs/apple')).toBe(true) + expect(regex.test('/docs')).toBe(false) + }) }) }) diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 315e2cedf672..975d2ccdeda0 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -492,6 +492,19 @@ export function getMiddlewareMatchers( }` source = `${OPTIONAL_MIDDLEWARE_NEXT_DATA_PREFIX}${source}${sourceSuffix}` + // Whether this matcher (before prepending `basePath`) matches the site + // root `/`. A catch-all like `/((?!api).*)` matches `/` because its single + // optional capture group can be empty. We need to know this before adding + // `basePath` so we can keep the bare `basePath` root matching too (see + // below). + const matchesRoot = + !!nextConfig.basePath && + !isRoot && + (() => { + const { regexStr } = tryToParsePath(source) + return regexStr ? new RegExp(regexStr).test('/') : false + })() + if (nextConfig.basePath) { source = `${nextConfig.basePath}${source}` } @@ -507,11 +520,23 @@ export function getMiddlewareMatchers( process.exit(1) } + // We know that parsed.regexStr is not undefined because we already + // checked that the source is valid. + let regexp = tryToParsePath(result.data).regexStr! + + // When `basePath` is set, prepending it turns a matcher that previously + // matched `/` into one anchored at `${basePath}/...`, so the bare + // `basePath` root (e.g. `/docs`) no longer matches even though `/` did + // without a `basePath`. Restore that by also accepting the bare + // `basePath` root. See https://github.com/vercel/next.js/issues/73786. + if (matchesRoot) { + const basePathRoot = `${escapeStringRegexp(nextConfig.basePath!)}[\\/#\\?]?$` + regexp = `^(?:${basePathRoot}|${regexp.replace(/^\^/, '')})` + } + return { ...rest, - // We know that parsed.regexStr is not undefined because we already - // checked that the source is valid. - regexp: tryToParsePath(result.data).regexStr!, + regexp, originalSource: originalSource || source, } })