Repository: vercel/next.js
Pull request: Fix @next/routing for i18n api and dynamic routes

This applies fixes/regression tests for reported issues in https://github.com/nextjs/adapters-wg/issues/1

Changed files:
filename: packages/next-routing/src/__tests__/i18n-resolve-routes.test.ts
patch: @@ -312,5 +312,51 @@ describe('resolveRoutes with i18n', () => {
       // Should not redirect for _next routes
       expect(result.redirect).toBeUndefined()
     })
+
+    it('should skip locale handling for api routes', async () => {
+      const result = await resolveRoutes({
+        ...baseParams,
+        url: new URL('http://example.com/api/ping'),
+        headers: new Headers({
+          'accept-language': 'fr',
+        }),
+        pathnames: ['/api/ping'],
+        i18n: i18nConfig,
+      })
+
+      // Should not redirect for api routes
+      expect(result.redirect).toBeUndefined()
+      expect(result.matchedPathname).toBe('/api/ping')
+    })
+
+    it('should skip locale handling for nested api routes', async () => {
+      const result = await resolveRoutes({
+        ...baseParams,
+        url: new URL('http://example.com/api/users/123'),
+        headers: new Headers({
+          'accept-language': 'ja',
+          cookie: 'NEXT_LOCALE=de',
+        }),
+        pathnames: ['/api/users/[id]'],
+        routes: {
+          ...baseParams.routes,
+          dynamicRoutes: [
+            {
+              sourceRegex: '^[/]?/api/users/(?<id>[^/]+?)(?:/)?$',
+              destination: '/api/users/[id]?id=$id',
+            },
+          ],
+        },
+        i18n: i18nConfigNoDomains,
+      })
+
+      // Should not redirect for api routes, even with locale cookies/headers
+      expect(result.redirect).toBeUndefined()
+      expect(result.matchedPathname).toBe('/api/users/[id]')
+      expect(result.routeMatches).toEqual({
+        '1': '123',
+        id: '123',
+      })
+    })
   })
 })

filename: packages/next-routing/src/__tests__/resolve-routes.test.ts
patch: @@ -658,3 +658,149 @@ describe('resolveRoutes - routes without destination', () => {
     expect(result.matchedPathname).toBe('/multi')
   })
 })
+
+describe('resolveRoutes - dynamic routes', () => {
+  it('should match dynamic route and return template pathname', async () => {
+    // This tests the case where a URL like /dynamic/page should match
+    // a dynamic route with template /dynamic/[slug]
+    const params = createBaseParams({
+      url: new URL('https://example.com/dynamic/page'),
+      routes: {
+        beforeMiddleware: [],
+        beforeFiles: [],
+        afterFiles: [],
+        dynamicRoutes: [
+          {
+            sourceRegex: '^[/]?/dynamic/(?<nxtPslug>[^/]+?)(?:/)?$',
+            destination: '/dynamic/[slug]?nxtPslug=$nxtPslug',
+          },
+        ],
+        onMatch: [],
+        fallback: [],
+      },
+      pathnames: ['/dynamic/[slug]'],
+    })
+
+    const result = await resolveRoutes(params)
+
+    expect(result.matchedPathname).toBe('/dynamic/[slug]')
+    expect(result.routeMatches).toEqual({
+      '1': 'page',
+      nxtPslug: 'page',
+    })
+  })
+
+  it('should match dynamic route with multiple segments', async () => {
+    const params = createBaseParams({
+      url: new URL('https://example.com/posts/2024/my-article'),
+      routes: {
+        beforeMiddleware: [],
+        beforeFiles: [],
+        afterFiles: [],
+        dynamicRoutes: [
+          {
+            sourceRegex: '^[/]?/posts/(?<year>[^/]+?)/(?<slug>[^/]+?)(?:/)?$',
+            destination: '/posts/[year]/[slug]?year=$year&slug=$slug',
+          },
+        ],
+        onMatch: [],
+        fallback: [],
+      },
+      pathnames: ['/posts/[year]/[slug]'],
+    })
+
+    const result = await resolveRoutes(params)
+
+    expect(result.matchedPathname).toBe('/posts/[year]/[slug]')
+    expect(result.routeMatches).toEqual({
+      '1': '2024',
+      '2': 'my-article',
+      year: '2024',
+      slug: 'my-article',
+    })
+  })
+
+  it('should match catch-all dynamic route', async () => {
+    const params = createBaseParams({
+      url: new URL('https://example.com/docs/getting-started/installation'),
+      routes: {
+        beforeMiddleware: [],
+        beforeFiles: [],
+        afterFiles: [],
+        dynamicRoutes: [
+          {
+            sourceRegex: '^[/]?/docs/(?<path>.+?)(?:/)?$',
+            destination: '/docs/[...path]?path=$path',
+          },
+        ],
+        onMatch: [],
+        fallback: [],
+      },
+      pathnames: ['/docs/[...path]'],
+    })
+
+    const result = await resolveRoutes(params)
+
+    expect(result.matchedPathname).toBe('/docs/[...path]')
+    expect(result.routeMatches).toEqual({
+      '1': 'getting-started/installation',
+      path: 'getting-started/installation',
+    })
+  })
+
+  it('should not match dynamic route when pathname template is not in pathnames list', async () => {
+    const params = createBaseParams({
+      url: new URL('https://example.com/dynamic/page'),
+      routes: {
+        beforeMiddleware: [],
+        beforeFiles: [],
+        afterFiles: [],
+        dynamicRoutes: [
+          {
+            sourceRegex: '^[/]?/dynamic/(?<nxtPslug>[^/]+?)(?:/)?$',
+            destination: '/dynamic/[slug]?nxtPslug=$nxtPslug',
+          },
+        ],
+        onMatch: [],
+        fallback: [],
+      },
+      pathnames: ['/other-page'], // /dynamic/[slug] is not in the list
+    })
+
+    const result = await resolveRoutes(params)
+
+    expect(result.matchedPathname).toBeUndefined()
+  })
+
+  it('should apply onMatch headers for dynamic routes', async () => {
+    const params = createBaseParams({
+      url: new URL('https://example.com/api/users'),
+      routes: {
+        beforeMiddleware: [],
+        beforeFiles: [],
+        afterFiles: [],
+        dynamicRoutes: [
+          {
+            sourceRegex: '^[/]?/api/(?<resource>[^/]+?)(?:/)?$',
+            destination: '/api/[resource]?resource=$resource',
+          },
+        ],
+        onMatch: [
+          {
+            sourceRegex: '.*',
+            headers: {
+              'x-matched': 'true',
+            },
+          },
+        ],
+        fallback: [],
+      },
+      pathnames: ['/api/[resource]'],
+    })
+
+    const result = await resolveRoutes(params)
+
+    expect(result.matchedPathname).toBe('/api/[resource]')
+    expect(result.resolvedHeaders?.get('x-matched')).toBe('true')
+  })
+})

filename: packages/next-routing/src/resolve-routes.ts
patch: @@ -163,7 +163,11 @@ function matchesPathname(
 function matchDynamicRoute(
   pathname: string,
   route: Route
-): { matched: boolean; params?: Record<string, string> } {
+): {
+  matched: boolean
+  params?: Record<string, string>
+  destinationPathname?: string
+} {
   const regex = new RegExp(route.sourceRegex)
   const match = pathname.match(regex)
 
@@ -185,7 +189,16 @@ function matchDynamicRoute(
     Object.assign(params, match.groups)
   }
 
-  return { matched: true, params }
+  // Compute destination pathname by applying the destination template
+  let destinationPathname: string | undefined
+  if (route.destination) {
+    const replaced = replaceDestination(route.destination, match, {})
+    // Extract just the pathname part (before any query string)
+    const [pathPart] = replaced.split('?')
+    destinationPathname = pathPart
+  }
+
+  return { matched: true, params, destinationPathname }
 }
 
 /**
@@ -242,8 +255,10 @@ function checkDynamicRoutes(
       )
 
       if (hasResult.matched && missingMatched) {
-        // Check if the current pathname is in the provided pathnames list
-        const matchedPath = matchesPathname(checkUrl.pathname, pathnames)
+        // Check if the destination pathname (template path) is in the provided pathnames list
+        // For dynamic routes, the destination contains the template path like /dynamic/[slug]
+        const pathnameToCheck = match.destinationPathname || checkUrl.pathname
+        const matchedPath = matchesPathname(pathnameToCheck, pathnames)
         if (matchedPath) {
           const finalHeaders = applyOnMatchHeaders(onMatchRoutes, headers)
           return {
@@ -302,8 +317,8 @@ export async function resolveRoutes(
       ? currentUrl.pathname.slice(basePath.length) || '/'
       : currentUrl.pathname
 
-    // Skip locale handling for _next routes
-    if (!pathname.startsWith('/_next/')) {
+    // Skip locale handling for _next and api routes
+    if (!pathname.startsWith('/_next/') && !pathname.startsWith('/api/')) {
       const hostname = currentUrl.hostname
       const cookieHeader = currentHeaders.get('cookie') || undefined
       const acceptLanguageHeader =
@@ -675,8 +690,10 @@ export async function resolveRoutes(
       )
 
       if (hasResult.matched && missingMatched) {
-        // Check if the current pathname is in the provided pathnames list
-        matchedPath = matchesPathname(currentUrl.pathname, pathnames)
+        // Check if the destination pathname (template path) is in the provided pathnames list
+        // For dynamic routes, the destination contains the template path like /dynamic/[slug]
+        const pathnameToCheck = match.destinationPathname || currentUrl.pathname
+        matchedPath = matchesPathname(pathnameToCheck, pathnames)
         if (matchedPath) {
           const finalHeaders = applyOnMatchHeaders(
             routes.onMatch,