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
2 changes: 2 additions & 0 deletions packages/docs/src/pages/GettingStarted.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/pages/advanced/MultipleEntrypoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 60 additions & 2 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down
192 changes: 130 additions & 62 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
@@ -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: <Page />,
});
},
);
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 (`<html>…<body>{children}</body></html>`).
- `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 `<Outlet />` (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 <h1>Home</h1>;
}
```

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 (
<section>
<nav>{/* persistent dashboard navigation */}</nav>
<Outlet />
</section>
);
}
```

## 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: <App ssrPath={pathname} />,
}));
export default function BlogPost({ params }: { params: { slug: string } }) {
return <article>Post: {params.slug}</article>;
}
```

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
6 changes: 0 additions & 6 deletions packages/example-fs-routing/src/App.tsx

This file was deleted.

29 changes: 0 additions & 29 deletions packages/example-fs-routing/src/entries.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ export default function About() {
<div>
<h1>About</h1>
<p>
This example demonstrates file-system routing with{" "}
This example demonstrates the built-in file-system routing of{" "}
<a href="https://github.com/uhyo/funstack-static">FUNSTACK Static</a>.
</p>
<p>
Routes are derived from the file structure under <code>src/pages/</code>{" "}
using Vite&apos;s <code>import.meta.glob</code>, 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.
</p>
</div>
);
Expand Down
15 changes: 0 additions & 15 deletions packages/example-fs-routing/src/pages/blog/index.tsx

This file was deleted.

Loading