diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx
index 19fa6ca..4e1a8eb 100644
--- a/packages/docs/src/pages/learn/FileSystemRouting.mdx
+++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx
@@ -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() {
diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx
new file mode 100644
index 0000000..b8d0fd8
--- /dev/null
+++ b/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx
@@ -0,0 +1,8 @@
+export default function FeaturedPosts() {
+ return (
+
+
Featured Posts
+
blog-featured
+
+ );
+}
diff --git a/packages/static/e2e/tests-dev/fs-routing.spec.ts b/packages/static/e2e/tests-dev/fs-routing.spec.ts
index 2a6aa85..57227e4 100644
--- a/packages/static/e2e/tests-dev/fs-routing.spec.ts
+++ b/packages/static/e2e/tests-dev/fs-routing.spec.ts
@@ -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(
diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts
index ded2f3c..2b128ac 100644
--- a/packages/static/e2e/tests/fs-routing.spec.ts
+++ b/packages/static/e2e/tests/fs-routing.spec.ts
@@ -6,6 +6,7 @@ test.describe("File-system routing build output", () => {
"/",
"/about",
"/blog",
+ "/blog/featured",
"/blog/hello",
"/blog/world",
"/dashboard",
@@ -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");
diff --git a/packages/static/src/fs-routes/nextAdapter.test.ts b/packages/static/src/fs-routes/nextAdapter.test.ts
index eaf7220..c4f6a7f 100644
--- a/packages/static/src/fs-routes/nextAdapter.test.ts
+++ b/packages/static/src/fs-routes/nextAdapter.test.ts
@@ -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",
diff --git a/packages/static/src/fs-routes/nextAdapter.ts b/packages/static/src/fs-routes/nextAdapter.ts
index 19bd4c1..f9e5cb5 100644
--- a/packages/static/src/fs-routes/nextAdapter.ts
+++ b/packages/static/src/fs-routes/nextAdapter.ts
@@ -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.
@@ -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) {
@@ -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 }];
}
@@ -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;
}
@@ -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.
*/
@@ -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();
+ // 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();
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;