diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts
index 40bf44edc397e6..eb1acad07433a7 100644
--- a/packages/next/src/build/index.ts
+++ b/packages/next/src/build/index.ts
@@ -933,7 +933,9 @@ export default async function build(
validFileMatcher.isAppRouterPage(absolutePath) ||
// For now we only collect the root /not-found page in the app
// directory as the 404 fallback
- validFileMatcher.isRootNotFound(absolutePath),
+ validFileMatcher.isRootNotFound(absolutePath) ||
+ // Default slots are also valid pages, and need to be considered during path normalization
+ validFileMatcher.isDefaultSlot(absolutePath),
ignorePartFilter: (part) => part.startsWith('_'),
})
)
diff --git a/packages/next/src/build/normalize-catchall-routes.test.ts b/packages/next/src/build/normalize-catchall-routes.test.ts
index 1ae0c771d883a8..636da8b6966fba 100644
--- a/packages/next/src/build/normalize-catchall-routes.test.ts
+++ b/packages/next/src/build/normalize-catchall-routes.test.ts
@@ -96,4 +96,27 @@ describe('normalizeCatchallRoutes', () => {
],
})
})
+
+ it('should not add the catch-all route to segments that have a more specific default', () => {
+ const appPaths = {
+ '/': ['/page'],
+ '/[[...catchAll]]': ['/[[...catchAll]]/page'],
+ '/nested/[foo]/[bar]/default': [
+ '/nested/[foo]/[bar]/default',
+ '/nested/[foo]/[bar]/@slot/default',
+ ],
+ '/nested/[foo]/[bar]': ['/nested/[foo]/[bar]/@slot/page'],
+ '/nested/[foo]/[bar]/[baz]/default': [
+ '/nested/[foo]/[bar]/@slot/[baz]/default',
+ '/[[...catchAll]]/page',
+ ],
+ '/nested/[foo]/[bar]/[baz]': ['/nested/[foo]/[bar]/@slot/[baz]/page'],
+ }
+
+ const initialAppPaths = JSON.parse(JSON.stringify(appPaths))
+
+ normalizeCatchAllRoutes(appPaths)
+
+ expect(appPaths).toMatchObject(initialAppPaths)
+ })
})
diff --git a/packages/next/src/build/normalize-catchall-routes.ts b/packages/next/src/build/normalize-catchall-routes.ts
index f24f95897e77be..f84c62db21694d 100644
--- a/packages/next/src/build/normalize-catchall-routes.ts
+++ b/packages/next/src/build/normalize-catchall-routes.ts
@@ -36,12 +36,15 @@ export function normalizeCatchAllRoutes(
0,
normalizedCatchAllRoute.search(catchAllRouteRegex)
)
-
if (
// check if the appPath could match the catch-all
appPath.startsWith(normalizedCatchAllRouteBasePath) &&
// check if there's not already a slot value that could match the catch-all
- !appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute))
+ !appPaths[appPath].some((path) =>
+ hasMatchedSlots(path, catchAllRoute)
+ ) &&
+ // check if the catch-all is not already matched by a default route
+ !appPaths[`${appPath}/default`]
) {
appPaths[appPath].push(catchAllRoute)
}
diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts
index eb949220ffb192..d99993f196faad 100644
--- a/packages/next/src/export/index.ts
+++ b/packages/next/src/export/index.ts
@@ -55,6 +55,7 @@ import isError from '../lib/is-error'
import { needsExperimentalReact } from '../lib/needs-experimental-react'
import { formatManifest } from '../build/manifests/formatter/format-manifest'
import { validateRevalidate } from '../server/lib/patch-fetch'
+import { isDefaultRoute } from '../lib/is-default-route'
function divideSegments(number: number, segments: number): number[] {
const result = []
@@ -559,9 +560,13 @@ export async function exportAppImpl(
]
const filteredPaths = exportPaths.filter(
- // Remove API routes
(route) =>
- exportPathMap[route]._isAppDir || !isAPIRoute(exportPathMap[route].page)
+ // Remove default routes -- they don't need to be exported
+ // and are only used for parallel route normalization
+ !isDefaultRoute(exportPathMap[route].page) &&
+ (exportPathMap[route]._isAppDir ||
+ // Remove API routes
+ !isAPIRoute(exportPathMap[route].page))
)
if (filteredPaths.length !== exportPaths.length) {
diff --git a/packages/next/src/lib/is-default-route.ts b/packages/next/src/lib/is-default-route.ts
new file mode 100644
index 00000000000000..adbd42d508f7fc
--- /dev/null
+++ b/packages/next/src/lib/is-default-route.ts
@@ -0,0 +1,3 @@
+export function isDefaultRoute(value?: string) {
+ return value?.endsWith('/default')
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts
index 2a5b0f05be2fc1..bb7ef7cc975873 100644
--- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts
@@ -19,9 +19,11 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide
this.normalizers = new DevAppNormalizers(appDir, extensions)
- // Match any page file that ends with `/page.${extension}` under the app
+ // Match any page file that ends with `/page.${extension}` or `/default.${extension}` under the app
// directory.
- this.expression = new RegExp(`[/\\\\]page\\.(?:${extensions.join('|')})$`)
+ this.expression = new RegExp(
+ `[/\\\\](page|default)\\.(?:${extensions.join('|')})$`
+ )
}
protected async transform(
diff --git a/packages/next/src/server/lib/find-page-file.ts b/packages/next/src/server/lib/find-page-file.ts
index d51935a761d170..42b046e8efc540 100644
--- a/packages/next/src/server/lib/find-page-file.ts
+++ b/packages/next/src/server/lib/find-page-file.ts
@@ -127,6 +127,10 @@ export function createValidFileMatcher(
return validExtensionFileRegex.test(filePath) || isMetadataFile(filePath)
}
+ function isDefaultSlot(filePath: string) {
+ return filePath.endsWith(`default.${pageExtensions[0]}`)
+ }
+
function isRootNotFound(filePath: string) {
if (!appDirPath) {
return false
@@ -143,5 +147,6 @@ export function createValidFileMatcher(
isAppRouterPage,
isMetadataFile,
isRootNotFound,
+ isDefaultSlot,
}
}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx
new file mode 100644
index 00000000000000..32aad010387345
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return
/[locale]/[[...catchAll]]/page.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/default.tsx
new file mode 100644
index 00000000000000..335a52a0d00de5
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/default.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return /[locale]/nested/[foo]/[bar]/@slot/[baz]/default.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx
new file mode 100644
index 00000000000000..6078f8edee1d12
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page({ params }) {
+ return /[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx
new file mode 100644
index 00000000000000..5a33f15b805915
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return /[locale]/nested/[foo]/[bar]/@slot/default.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx
new file mode 100644
index 00000000000000..32afde0e4032a5
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx
@@ -0,0 +1,3 @@
+export default function Foo() {
+ return /[locale]/nested/[foo]/[bar]/@slot/page.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx
new file mode 100644
index 00000000000000..b99ced4ae3d61f
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return /[locale]/nested/[foo]/[bar]/default.tsx
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx
new file mode 100644
index 00000000000000..f2619e0b884b96
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx
@@ -0,0 +1,8 @@
+export default function Layout({ children, slot }) {
+ return (
+ <>
+ Children: {children}
+ Slot: {slot}
+ >
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx
new file mode 100644
index 00000000000000..98b2ba6e286e8d
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ Children: {children}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx
new file mode 100644
index 00000000000000..0e3edd2c011b10
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx
@@ -0,0 +1,3 @@
+export default async function Home() {
+ return Root Page
+}
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js b/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js
new file mode 100644
index 00000000000000..807126e4cf0bf5
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js
@@ -0,0 +1,6 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {}
+
+module.exports = nextConfig
diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts b/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts
new file mode 100644
index 00000000000000..7c4fd86b93aad1
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts
@@ -0,0 +1,44 @@
+import { createNextDescribe } from 'e2e-utils'
+
+createNextDescribe(
+ 'parallel-routes-catchall-default',
+ {
+ files: __dirname,
+ },
+ ({ next }) => {
+ it('should match default paths before catch-all', async () => {
+ let browser = await next.browser('/en/nested')
+
+ // we have a top-level catch-all but the /nested dir doesn't have a default/page until the /[foo]/[bar] segment
+ // so we expect the top-level catch-all to render
+ expect(await browser.elementById('children').text()).toBe(
+ '/[locale]/[[...catchAll]]/page.tsx'
+ )
+
+ browser = await next.browser('/en/nested/foo/bar')
+
+ // we're now at the /[foo]/[bar] segment, so we expect the matched page to be the default (since there's no page defined)
+ expect(await browser.elementById('nested-children').text()).toBe(
+ '/[locale]/nested/[foo]/[bar]/default.tsx'
+ )
+
+ // we expect the slot to match since there's a page defined at this segment
+ expect(await browser.elementById('slot').text()).toBe(
+ '/[locale]/nested/[foo]/[bar]/@slot/page.tsx'
+ )
+
+ browser = await next.browser('/en/nested/foo/bar/baz')
+
+ // the page slot should still be the one matched at the /[foo]/[bar] segment because it's the default and we
+ // didn't define a page at the /[foo]/[bar]/[baz] segment
+ expect(await browser.elementById('nested-children').text()).toBe(
+ '/[locale]/nested/[foo]/[bar]/default.tsx'
+ )
+
+ // however we do have a slot for the `[baz]` segment and so we expect that to no match
+ expect(await browser.elementById('slot').text()).toBe(
+ '/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx'
+ )
+ })
+ }
+)
diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx
deleted file mode 100644
index 129f875a30b3a4..00000000000000
--- a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function Default() {
- return (
-
- )
-}