diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 9a6e81bf2590..6a4d6d2dc175 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -560,9 +560,6 @@ export default abstract class Server { if (url.locale?.path.detectedLocale) { req.url = formatUrl(url) addRequestMeta(req, '__nextStrippedLocale', true) - if (url.pathname === '/api' || url.pathname.startsWith('/api/')) { - return this.render404(req, res, parsedUrl) - } } if (!this.minimalMode || !parsedUrl.query.__nextLocale) { diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 1c618e7616a3..b101f838544e 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -317,8 +317,20 @@ export default class Router { currentPathnameNoBasePath, this.locales ) + const activeBasePath = keepBasePath ? this.basePath : '' + // don't match API routes when they are locale prefixed + // e.g. /api/hello shouldn't match /en/api/hello as a page + // rewrites/redirects can match though + if ( + !isCustomRoute && + localePathResult.detectedLocale && + localePathResult.pathname.match(/^\/api(?:\/|$)/) + ) { + continue + } + if (keepLocale) { if ( !testRoute.internal && diff --git a/test/e2e/i18n-api-support/index.test.ts b/test/e2e/i18n-api-support/index.test.ts new file mode 100644 index 000000000000..fa763d74d231 --- /dev/null +++ b/test/e2e/i18n-api-support/index.test.ts @@ -0,0 +1,71 @@ +import { createNext } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('i18n API support', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.end('hello world') + } + `, + 'pages/api/blog/[slug].js': ` + export default function handler(req, res) { + res.end('blog/[slug]') + } + `, + }, + nextConfig: { + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [], + afterFiles: [], + fallback: [ + { + source: '/api/:path*', + destination: 'https://example.vercel.sh/', + }, + ], + } + }, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should respond to normal API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/hello') + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello world') + }) + + it('should respond to normal dynamic API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/blog/first') + expect(res.status).toBe(200) + expect(await res.text()).toBe('blog/[slug]') + }) + + it('should fallback rewrite non-matching API request', async () => { + const paths = [ + '/fr/api/hello', + '/en/api/blog/first', + '/en/api/non-existent', + '/api/non-existent', + ] + + for (const path of paths) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + } + }) +})