Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ The built-in adapter follows Next.js App-Router conventions:

Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes.

### Route Precedence

Sibling routes are matched by specificity: **static segments match before dynamic segments, which match before catch-alls**. For example, with both `blog/featured/page.tsx` and `blog/[slug]/page.tsx`, the URL `/blog/featured` renders the static page, and any other `/blog/…` URL falls through to the dynamic one.

### Unsupported Syntaxes and Conflicts

The adapter fails the build with a clear error instead of producing broken routes when it encounters:

- **Optional catch-all segments** (`[[...param]]`) — use a catch-all (`[...param]`) plus a separate `page.tsx` for the parent route instead.
- **Parallel route slots** (`@slot`) and **intercepting routes** (`(.)segment`) — these Next.js features are not supported.
- **Conflicting pages** — two pages resolving to the same route, such as `(a)/foo/page.tsx` + `(b)/foo/page.tsx`, or sibling dynamic pages with different param names (`[a]` + `[b]`).
- **Duplicate files** — two page (or layout) files in the same directory, such as `page.tsx` next to `page.jsx`.

Multiple root layouts via route groups (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`) are supported.

```tsx
// src/pages/page.tsx
export default function Home() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function FeaturedPosts() {
return (
<div>
<h1>Featured Posts</h1>
<p data-testid="page-id">blog-featured</p>
</div>
);
}
7 changes: 7 additions & 0 deletions packages/static/e2e/tests-dev/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ test.describe("File-system routing (dev server)", () => {
await expect(page.getByTestId("slug")).toHaveText("hello");
});

test("renders a static page instead of a dynamic sibling route", async ({
page,
}) => {
await page.goto("/blog/featured");
await expect(page.getByTestId("page-id")).toHaveText("blog-featured");
});

test("wraps nested pages in their layout", async ({ page }) => {
await page.goto("/dashboard/settings");
await expect(page.getByTestId("dashboard-layout")).toHaveText(
Expand Down
8 changes: 8 additions & 0 deletions packages/static/e2e/tests/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ test.describe("File-system routing build output", () => {
"/",
"/about",
"/blog",
"/blog/featured",
"/blog/hello",
"/blog/world",
"/dashboard",
Expand Down Expand Up @@ -36,6 +37,13 @@ test.describe("File-system routing rendering", () => {
await expect(page.getByTestId("page-id")).toHaveText("blog-index");
});

test("renders a static page instead of a dynamic sibling route", async ({
page,
}) => {
await page.goto("/blog/featured");
await expect(page.getByTestId("page-id")).toHaveText("blog-featured");
});

test("statically generates dynamic routes with params", async ({ page }) => {
await page.goto("/blog/hello");
await expect(page.getByTestId("page-id")).toHaveText("blog-post");
Expand Down
152 changes: 152 additions & 0 deletions packages/static/src/fs-routes/nextAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,158 @@ describe("nextRoutes adapter", () => {
]);
});

it("orders static routes before dynamic siblings", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles(["blog/[slug]/page.tsx", "blog/about/page.tsx"]),
);
expect(simplify(tree)).toEqual([
{ path: "/blog/about", page: true, id: "blog/about/page.tsx" },
{ path: "/blog/:slug", page: true, id: "blog/[slug]/page.tsx" },
]);
});

it("orders static and dynamic routes before catch-all siblings", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles([
"docs/[...slug]/page.tsx",
"docs/[version]/page.tsx",
"docs/intro/page.tsx",
]),
);
expect(simplify(tree)).toEqual([
{ path: "/docs/intro", page: true, id: "docs/intro/page.tsx" },
{ path: "/docs/:version", page: true, id: "docs/[version]/page.tsx" },
{ path: "/docs/:slug*", page: true, id: "docs/[...slug]/page.tsx" },
]);
});

it("orders routes by specificity inside a layout", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles([
"blog/layout.tsx",
"blog/[slug]/page.tsx",
"blog/archive/page.tsx",
"blog/page.tsx",
]),
);
expect(simplify(tree)).toEqual([
{
path: "/blog",
page: false,
id: "blog/layout.tsx",
children: [
{ path: "/", page: true, id: "blog/page.tsx" },
{ path: "/archive", page: true, id: "blog/archive/page.tsx" },
{ path: "/:slug", page: true, id: "blog/[slug]/page.tsx" },
],
},
]);
});

it("orders a grouped layout with dynamic children after static siblings", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles([
"(app)/layout.tsx",
"(app)/[slug]/page.tsx",
"about/page.tsx",
]),
);
expect(simplify(tree)).toEqual([
{ path: "/about", page: true, id: "about/page.tsx" },
{
path: undefined,
page: false,
id: "(app)/layout.tsx",
children: [{ path: "/:slug", page: true, id: "(app)/[slug]/page.tsx" }],
},
]);
});

it("rejects optional catch-all segments", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["docs/[[...slug]]/page.tsx"])),
).toThrow(/Optional catch-all segments/);
});

it("rejects parallel route slots", () => {
const adapter = nextRoutes();
expect(() => adapter.buildRoutes(makeFiles(["@modal/page.tsx"]))).toThrow(
/Parallel route slots/,
);
});

it("rejects intercepting routes", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["feed/(..)photo/page.tsx"])),
).toThrow(/Intercepting routes/);
});

it("rejects two pages resolving to the same route via route groups", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["(a)/foo/page.tsx", "(b)/foo/page.tsx"])),
).toThrow(/resolve to the same route/);
});

it("rejects sibling dynamic pages with different param names", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(
makeFiles(["blog/[a]/page.tsx", "blog/[b]/page.tsx"]),
),
).toThrow(/resolve to the same route/);
});

it("rejects duplicate page files in one directory", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["about/page.tsx", "about/page.jsx"])),
).toThrow(/Duplicate page files/);
});

it("allows multiple root layouts via route groups", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles([
"(marketing)/layout.tsx",
"(marketing)/page.tsx",
"(shop)/layout.tsx",
"(shop)/cart/page.tsx",
]),
);
expect(simplify(tree)).toEqual([
{
path: undefined,
page: false,
id: "(marketing)/layout.tsx",
children: [{ path: "/", page: true, id: "(marketing)/page.tsx" }],
},
{
path: undefined,
page: false,
id: "(shop)/layout.tsx",
children: [{ path: "/cart", page: true, id: "(shop)/cart/page.tsx" }],
},
]);
});

it("allows a dynamic page next to a catch-all sibling", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(
makeFiles(["docs/[page]/page.tsx", "docs/[...rest]/page.tsx"]),
);
expect(simplify(tree)).toEqual([
{ path: "/docs/:page", page: true, id: "docs/[page]/page.tsx" },
{ path: "/docs/:rest*", page: true, id: "docs/[...rest]/page.tsx" },
]);
});

it("honours custom page/layout file names", () => {
const adapter = nextRoutes({
pageFileName: "index",
Expand Down
124 changes: 124 additions & 0 deletions packages/static/src/fs-routes/nextAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ function classify(
return undefined;
}

/**
* Rejects directory segments using Next.js syntaxes that this adapter does not
* support, so they fail loudly instead of silently producing broken routes.
*/
function validateSegment(segment: string, filePath: string): void {
if (/^\[\[.*\]\]$/.test(segment)) {
throw new Error(
`Optional catch-all segments ("${segment}" in "${filePath}") are not supported. ` +
`Use a catch-all segment ([...param]) plus a separate page for the parent route instead.`,
);
}
if (segment.startsWith("@")) {
throw new Error(
`Parallel route slots ("${segment}" in "${filePath}") are not supported.`,
);
}
if (/^\(\.{1,3}\)/.test(segment)) {
throw new Error(
`Intercepting routes ("${segment}" in "${filePath}") are not supported.`,
);
}
}

/**
* Converts a directory segment to its URL contribution in FUNSTACK Router
* syntax, or `null` when the segment does not affect the URL.
Expand All @@ -75,6 +98,52 @@ function urlSegment(segment: string): string | null {
return segment;
}

/**
* Matching specificity of a router URL segment: static segments match before
* dynamic ones, which match before catch-alls.
*/
function segmentRank(segment: string): number {
if (!segment.startsWith(":")) return 0;
return segment.endsWith("*") ? 2 : 1;
}

/**
* Per-segment specificity ranks of a route node, used to order sibling routes.
*
* A pathless layout (a layout in a route group) consumes no pathname itself,
* so it is ranked by its greediest descendants: the element-wise maximum of
* its children's rank vectors.
*/
function rankVector(node: FsRouteTreeNode): number[] {
if (node.path !== undefined) {
return node.path.split("/").filter(Boolean).map(segmentRank);
}
const vectors = (node.children ?? []).map(rankVector);
const length = Math.max(0, ...vectors.map((vector) => vector.length));
const result: number[] = [];
for (let i = 0; i < length; i++) {
result.push(Math.max(0, ...vectors.map((vector) => vector[i] ?? 0)));
}
return result;
}

/**
* Orders sibling routes so that more specific routes match first: FUNSTACK
* Router matches routes in definition order (first match wins), so a dynamic
* or catch-all route emitted before a static sibling would shadow it.
*
* The sort is stable; equally-ranked siblings keep their alphabetical order.
*/
function compareNodes(a: FsRouteTreeNode, b: FsRouteTreeNode): number {
const rankA = rankVector(a);
const rankB = rankVector(b);
const length = Math.min(rankA.length, rankB.length);
for (let i = 0; i < length; i++) {
if (rankA[i] !== rankB[i]) return rankA[i]! - rankB[i]!;
}
return rankA.length - rankB.length;
}

function ensureDir(root: TrieNode, dirs: string[]): TrieNode {
let current = root;
for (const segment of dirs) {
Expand Down Expand Up @@ -114,6 +183,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] {
for (const child of childNodes) {
children.push(...emit(child, []));
}
children.sort(compareNodes);
const path = here.length === 0 ? undefined : `/${here.join("/")}`;
return [{ path, module: node.layout, page: false, children }];
}
Expand All @@ -126,6 +196,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] {
for (const child of childNodes) {
result.push(...emit(child, here));
}
result.sort(compareNodes);
return result;
}

Expand All @@ -143,6 +214,15 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] {
* Other files in the routes directory are ignored, so helpers and components
* may be co-located with routes.
*
* Sibling routes are ordered by specificity — static segments match before
* dynamic segments, which match before catch-alls — so a dynamic route never
* shadows a static sibling.
*
* `buildRoutes` throws on unsupported Next.js syntaxes (optional catch-all
* `[[...param]]`, parallel route slots `@slot`, intercepting routes
* `(.)segment`) and on conflicting route files (two pages resolving to the
* same route, or duplicate page/layout files in one directory).
*
* @experimental File-system routing is experimental and not yet subject to
* semantic versioning.
*/
Expand All @@ -154,10 +234,54 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter {
name: "next",
buildRoutes(files: FsRouteFile[]): FsRouteTreeNode[] {
const root: TrieNode = { segment: "", children: new Map() };
// Route position each page/layout occupies, with dynamic segments
// normalized so that e.g. `[a]` and `[b]` at the same position conflict.
// Exact directory each page/layout file lives in, to detect duplicate
// files for the same node (e.g. `page.tsx` next to `page.jsx`).
const filesByDir = new Map<string, string>();
// Route position of each page, with dynamic segments normalized so that
// e.g. `[a]` and `[b]` pages at the same position conflict. Layouts are
// exempt: multiple layouts at one position via route groups are valid
// (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`).
const pagePositions = new Map<string, string>();
for (const file of files) {
const { dirs, base } = splitFilePath(file.filePath);
const kind = classify(base, pageFileName, layoutFileName);
if (!kind) continue;
for (const segment of dirs) {
validateSegment(segment, file.filePath);
}
const dirKey = `${kind} ${dirs.join("/")}`;
const sameDir = filesByDir.get(dirKey);
if (sameDir !== undefined) {
throw new Error(
`Duplicate ${kind} files "${sameDir}" and "${file.filePath}": ` +
`a directory may contain only one ${kind} file.`,
);
}
filesByDir.set(dirKey, file.filePath);
if (kind === "page") {
const position = dirs
.map(urlSegment)
.filter((segment) => segment !== null)
.map((segment) =>
segment.startsWith(":")
? segment.endsWith("*")
? "[...]"
: "[]"
: segment,
)
.join("/");
const conflicting = pagePositions.get(position);
if (conflicting !== undefined) {
throw new Error(
`Route files "${conflicting}" and "${file.filePath}" conflict: ` +
`they resolve to the same route. Routes are matched first-match-wins, ` +
`so one of the pages would never be reachable.`,
);
}
pagePositions.set(position, file.filePath);
}
const node = ensureDir(root, dirs);
if (kind === "page") {
node.page = file.module;
Expand Down