diff --git a/packages/docs/src/pages/GettingStarted.mdx b/packages/docs/src/pages/GettingStarted.mdx
index 8904c65..927cd01 100644
--- a/packages/docs/src/pages/GettingStarted.mdx
+++ b/packages/docs/src/pages/GettingStarted.mdx
@@ -128,6 +128,8 @@ export default function App() {
}
```
+Prefer convention over configuration? FUNSTACK Static also has built-in [file-system routing](/learn/file-system-routing) that maps a `pages/` directory to routes automatically.
+
### 5. Start Development Server
```bash
diff --git a/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx b/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx
index 3f050e6..9d80ded 100644
--- a/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx
+++ b/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx
@@ -9,6 +9,8 @@ Use the `entries` option when you want to build a **multi-page static site** whe
- **Single-entry mode** (`root` + `app`): One HTML file, client-side routing between pages. Best for app-like experiences where dynamic data loading and client-side interactivity are heavily used, and SEO is less of a concern (e.g., dashboards, web apps).
- **Multiple entries mode** (`entries`): Multiple HTML files, each independently pre-rendered. Best for content sites (blogs, docs, marketing pages) where SEO and fast initial load are priorities. Client-side routing is still possible by using a router library with SSR support.
+> If you want a `pages/` directory mapped to routes automatically instead of writing entries by hand, use built-in [File-System Routing](/learn/file-system-routing), which generates entries for you.
+
## Basic Setup
### 1. Configure Vite
diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx
index 1c695ab..8c5aeac 100644
--- a/packages/docs/src/pages/api/FunstackStatic.mdx
+++ b/packages/docs/src/pages/api/FunstackStatic.mdx
@@ -10,7 +10,7 @@ import funstackStatic from "@funstack/static";
## Usage
-There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages).
+There are three configuration modes: **single-entry** (one HTML page), **multiple entries** (multiple HTML pages), and **file-system routing** (pages mapped from the file system).
### Single-Entry Mode
@@ -53,9 +53,37 @@ export default defineConfig({
See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide.
+### File-System Routing Mode
+
+> **Experimental.** Not yet covered by semantic versioning.
+
+Use `fsRoutes` to map pages from a directory to routes, rendered with FUNSTACK Router:
+
+```typescript
+// vite.config.ts
+import funstackStatic from "@funstack/static";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [
+ funstackStatic({
+ ssr: true,
+ fsRoutes: {
+ dir: "./src/pages",
+ root: "./src/root.tsx",
+ },
+ }),
+ react(),
+ ],
+});
+```
+
+`ssr: true` is required for the dev server to render pages. See [File-System Routing](/learn/file-system-routing) for a full guide.
+
## Options
-The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive.
+The plugin accepts exactly one of `root` + `app` (single-entry), `entries` (multiple entries), or `fsRoutes` (file-system routing). These modes are mutually exclusive.
### root
@@ -160,6 +188,36 @@ export default function getEntries(): EntryDefinition[] {
See [Multiple Entrypoints](/advanced/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators.
+### fsRoutes
+
+**Type:** `FsRoutesConfig`
+**Required in:** file-system routing mode
+**Experimental** — not yet covered by semantic versioning.
+
+Enables built-in file-system routing. Pages discovered under `fsRoutes.dir` are mapped to routes via an adapter and rendered with FUNSTACK Router. Requires `@funstack/router` to be installed.
+
+Cannot be used together with `root`, `app`, or `entries`.
+
+```typescript
+funstackStatic({
+ fsRoutes: {
+ dir: "./src/pages",
+ root: "./src/root.tsx",
+ adapter: "./src/my-adapter.ts", // optional
+ },
+});
+```
+
+`FsRoutesConfig` fields:
+
+- **`dir`** (optional, default `"./src/pages"`) — directory scanned for route files, relative to the Vite root.
+- **`root`** (required) — path to the root (HTML shell) component module.
+- **`adapter`** (optional) — path to a module that `export default`s an `FsRoutesAdapter`. Defaults to the built-in Next.js-like adapter (`nextRoutes()` from `@funstack/static/fs-routes`).
+
+Enable [`ssr`](#ssr-optional) alongside `fsRoutes` — it is required for the dev server to render pages and recommended in general.
+
+See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters.
+
### publicOutDir (optional)
**Type:** `string`
diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx
index fad9e33..4736151 100644
--- a/packages/docs/src/pages/learn/FileSystemRouting.mdx
+++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx
@@ -1,95 +1,163 @@
# File-System Routing
-FUNSTACK Static does not include a built-in file-system router, but you can implement one in userland using Vite's `import.meta.glob` and a router library like [FUNSTACK Router](https://github.com/uhyo/funstack-router).
+> **Experimental.** File-system routing is experimental. Its API may change in a minor release and is **not** yet covered by semantic versioning.
-## How It Works
+FUNSTACK Static includes **built-in file-system routing**. Pages discovered in a directory are automatically mapped to routes and rendered with [FUNSTACK Router](https://github.com/uhyo/funstack-router), with one static HTML file generated per route.
-The idea is to use `import.meta.glob` to discover page components from a `pages/` directory at compile time, then convert the file paths into route definitions.
+The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box.
-```tsx
-import { route, type RouteDefinition } from "@funstack/router/server";
-
-const pageModules = import.meta.glob<{ default: React.ComponentType }>(
- "./pages/**/*.tsx",
- { eager: true },
-);
-
-function filePathToUrlPath(filePath: string): string {
- let urlPath = filePath.replace(/^\.\/pages/, "").replace(/\.tsx$/, "");
- if (urlPath.endsWith("/index")) {
- urlPath = urlPath.slice(0, -"/index".length);
- }
- return urlPath || "/";
-}
+## Requirements
-export const routes: RouteDefinition[] = Object.entries(pageModules).map(
- ([filePath, module]) => {
- const Page = module.default;
- return route({
- path: filePathToUrlPath(filePath),
- component: ,
- });
- },
-);
+File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed:
+
+```sh
+npm install @funstack/router
```
-With this setup, files in the `pages/` directory are automatically mapped to routes:
+## Setup
+
+Enable file-system routing with the `fsRoutes` option:
+
+```typescript
+// vite.config.ts
+import funstackStatic from "@funstack/static";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [
+ funstackStatic({
+ ssr: true,
+ fsRoutes: {
+ dir: "./src/pages",
+ root: "./src/root.tsx",
+ },
+ }),
+ react(),
+ ],
+});
+```
-| File | Route |
-| ---------------------- | -------- |
-| `pages/index.tsx` | `/` |
-| `pages/about.tsx` | `/about` |
-| `pages/blog/index.tsx` | `/blog` |
+- `dir` — the directory scanned for route files (default `./src/pages`).
+- `root` — the HTML shell component (`…
{children}`).
+- `adapter` — optional path to a custom adapter module (defaults to the built-in Next.js-like adapter).
-## Why import.meta.glob?
+`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes.
-Using `import.meta.glob` has two key advantages:
+> **Enable [`ssr`](/api/funstack-static).** Pages are server components rendered through FUNSTACK Router. `ssr: true` is **required for the dev server** (`vite dev`) to render your pages — without it, the dev server can only render the app shell — and it is recommended in general for SEO and faster initial load. Production builds work with `ssr` either way. Lifting this dev-server requirement is tracked in [#124](https://github.com/uhyo/funstack-static/issues/124).
-- **Automatic discovery** — you don't need to manually register each page. Just add a new `.tsx` file and it becomes a route.
-- **Hot module replacement** — Vite tracks the glob pattern, so adding or removing page files in development triggers an automatic update without a server restart.
+## The Next.js-like Convention
-## Static Generation
+The built-in adapter follows Next.js App-Router conventions:
-To generate static HTML for each route, derive [entry definitions](/api/entry-definition) from the route list:
+| File | Route | Notes |
+| ------------------------------------ | -------------- | ----------------------------- |
+| `pages/page.tsx` | `/` | A page for its directory |
+| `pages/about/page.tsx` | `/about` | |
+| `pages/blog/page.tsx` | `/blog` | |
+| `pages/blog/[slug]/page.tsx` | `/blog/:slug` | Dynamic segment |
+| `pages/docs/[...slug]/page.tsx` | `/docs/:slug*` | Catch-all segment |
+| `pages/(marketing)/contact/page.tsx` | `/contact` | `(group)` does not affect URL |
+
+- **`page.tsx`** — `export default` a React component for the route.
+- **`layout.tsx`** — `export default` a layout that wraps its directory and descendants. A layout must render `` (from `@funstack/router`) where child routes should appear.
+
+Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes.
```tsx
-import type { EntryDefinition } from "@funstack/static/entries";
-import type { RouteDefinition } from "@funstack/router/server";
-
-function collectPaths(routes: RouteDefinition[]): string[] {
- const paths: string[] = [];
- for (const route of routes) {
- if (route.children) {
- paths.push(...collectPaths(route.children));
- } else if (route.path !== undefined && route.path !== "*") {
- paths.push(route.path);
- }
- }
- return paths;
+// src/pages/page.tsx
+export default function Home() {
+ return
Home
;
}
+```
-function pathToEntryPath(path: string): string {
- if (path === "/") return "index.html";
- return `${path.slice(1)}.html`;
+```tsx
+// src/pages/dashboard/layout.tsx
+import { Outlet } from "@funstack/router";
+
+export default function DashboardLayout() {
+ return (
+
+
+
+
+ );
+}
+```
+
+## Dynamic Routes and Static Generation
+
+Because FUNSTACK Static generates a static site, every page must be enumerated at build time. Dynamic routes are pre-rendered by exporting `generateStaticParams` from the page module, similar to Next.js:
+
+```tsx
+// src/pages/blog/[slug]/page.tsx
+export function generateStaticParams() {
+ return [{ slug: "hello" }, { slug: "world" }];
}
-export default function getEntries(): EntryDefinition[] {
- return collectPaths(routes).map((pathname) => ({
- path: pathToEntryPath(pathname),
- root: () => import("./root"),
- app: ,
- }));
+export default function BlogPost({ params }: { params: { slug: string } }) {
+ return Post: {params.slug};
}
```
-This produces one HTML file per route at build time.
+This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop.
+
+A dynamic route without `generateStaticParams` is **not** pre-rendered (a warning is logged); it still resolves on the client via the SPA fallback.
+
+> **Note:** Because static hosting serves one pre-rendered RSC payload per page, soft client-side navigation between different values of the _same_ dynamic route reflects the params of the initially-loaded page. Loading a dynamic URL directly (or via the SPA fallback) always renders the correct params. Static routes and layouts navigate fully on the client.
+
+## Custom Conventions (Adapters)
+
+The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Point `adapter` at a module that `export default`s an adapter to use a different convention:
+
+```typescript
+// vite.config.ts
+funstackStatic({
+ fsRoutes: {
+ dir: "./src/pages",
+ root: "./src/root.tsx",
+ adapter: "./src/my-adapter.ts",
+ },
+});
+```
+
+```tsx
+// src/my-adapter.ts
+import type { FsRoutesAdapter } from "@funstack/static/fs-routes";
+
+const adapter: FsRoutesAdapter = {
+ name: "my-convention",
+ buildRoutes(files) {
+ // Map discovered files to a route tree.
+ // See the FsRouteTreeNode type for the expected shape.
+ return [];
+ },
+};
+
+export default adapter;
+```
+
+The built-in Next.js-like adapter is also exported, so you can wrap or configure it:
+
+```tsx
+// src/my-adapter.ts
+import { nextRoutes } from "@funstack/static/fs-routes";
+
+// e.g. use `index.tsx` instead of `page.tsx`
+export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" });
+```
## Full Example
For a complete working example, see the [`example-fs-routing`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing) package in the FUNSTACK Static repository.
+## Fully Custom Routing
+
+`fsRoutes` is a convenience built on the [`entries`](/api/funstack-static) option. If you need full control, you can write the entries module by hand — globbing pages with `import.meta.glob` and deriving [entry definitions](/api/entry-definition) yourself. The `@funstack/static/fs-routes` building blocks (`createFsRoutesEntries`, `nextRoutes`) are exported for this purpose.
+
## See Also
+- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option
- [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project
- [EntryDefinition](/api/entry-definition) - API reference for entry definitions
- [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture
diff --git a/packages/example-fs-routing/src/App.tsx b/packages/example-fs-routing/src/App.tsx
deleted file mode 100644
index e904c34..0000000
--- a/packages/example-fs-routing/src/App.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { Router } from "@funstack/router";
-import { routes } from "./routes";
-
-export default function App({ ssrPath }: { ssrPath: string }) {
- return ;
-}
diff --git a/packages/example-fs-routing/src/entries.tsx b/packages/example-fs-routing/src/entries.tsx
deleted file mode 100644
index 0a54ad3..0000000
--- a/packages/example-fs-routing/src/entries.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { EntryDefinition } from "@funstack/static/entries";
-import type { RouteDefinition } from "@funstack/router/server";
-import App from "./App";
-import { routes } from "./routes";
-
-function collectPaths(routes: RouteDefinition[]): string[] {
- const paths: string[] = [];
- for (const route of routes) {
- if (route.children) {
- paths.push(...collectPaths(route.children));
- } else if (route.path !== undefined && route.path !== "*") {
- paths.push(route.path);
- }
- }
- return paths;
-}
-
-function pathToEntryPath(path: string): string {
- if (path === "/") return "index.html";
- return `${path.slice(1)}.html`;
-}
-
-export default function getEntries(): EntryDefinition[] {
- return collectPaths(routes).map((pathname) => ({
- path: pathToEntryPath(pathname),
- root: () => import("./root"),
- app: ,
- }));
-}
diff --git a/packages/example-fs-routing/src/pages/about.tsx b/packages/example-fs-routing/src/pages/about/page.tsx
similarity index 58%
rename from packages/example-fs-routing/src/pages/about.tsx
rename to packages/example-fs-routing/src/pages/about/page.tsx
index 055b02b..2fbcb6d 100644
--- a/packages/example-fs-routing/src/pages/about.tsx
+++ b/packages/example-fs-routing/src/pages/about/page.tsx
@@ -3,13 +3,13 @@ export default function About() {
About
- This example demonstrates file-system routing with{" "}
+ This example demonstrates the built-in file-system routing of{" "}
FUNSTACK Static.
Routes are derived from the file structure under src/pages/{" "}
- using Vite's import.meta.glob, which also enables hot
- module replacement during development.
+ using the Next.js-like adapter, and rendered with FUNSTACK Router. The
+ convention is configurable via custom adapters.
- This page is at pages/blog/index.tsx, which maps to the{" "}
- /blog route.
-
-
- Nested directories create nested URL paths. An index.tsx{" "}
- file in a directory maps to the directory's path.
-
-
- );
-}
diff --git a/packages/example-fs-routing/src/pages/blog/page.tsx b/packages/example-fs-routing/src/pages/blog/page.tsx
new file mode 100644
index 0000000..9294f08
--- /dev/null
+++ b/packages/example-fs-routing/src/pages/blog/page.tsx
@@ -0,0 +1,16 @@
+export default function Blog() {
+ return (
+
+
Blog
+
+ This page is at pages/blog/page.tsx, which maps to the{" "}
+ /blog route.
+
+
+ Nested directories create nested URL paths. A layout.tsx{" "}
+ file in a directory wraps its pages (render{" "}
+ <Outlet /> where children should appear).
+