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
3 changes: 3 additions & 0 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default defineConfig({
dir: "./src/pages",
root: "./src/root.tsx",
adapter: "@funstack/static/fs-routes/next-adapter",
mode: "spa",
},
}),
react(),
Expand Down Expand Up @@ -205,6 +206,7 @@ funstackStatic({
dir: "./src/pages",
root: "./src/root.tsx",
adapter: "@funstack/static/fs-routes/next-adapter",
mode: "spa",
},
});
```
Expand All @@ -214,6 +216,7 @@ funstackStatic({
- **`dir`** (required) — directory scanned for route files, relative to the Vite root. Commonly `"./src/pages"`.
- **`root`** (required) — path to the root (HTML shell) component module.
- **`adapter`** (required) — a module that `export default`s an `FsRoutesAdapter`, given as a bare module specifier or a path relative to the Vite root. Use the bundled `@funstack/static/fs-routes/next-adapter` for the built-in Next.js-like convention, or point it at your own module for a custom one.
- **`mode`** (required) — `"spa"` renders pages with FUNSTACK Router's `<Router>`, so links within the site are followed by soft client-side navigation (with the Server Component output of every page pre-rendered as RSC chunks fetched on navigation). `"mpa"` renders pages with `<RouteDisplay>`, which never intercepts navigations: every link loads the destination's own HTML, and only each page's own RSC payload is emitted. See [SPA or MPA](/learn/file-system-routing#spa-or-mpa).

Enabling [`ssr`](#ssr-optional) alongside `fsRoutes` is optional but recommended for SEO and faster initial load.

Expand Down
27 changes: 26 additions & 1 deletion packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default defineConfig({
dir: "./src/pages",
root: "./src/root.tsx",
adapter: "@funstack/static/fs-routes/next-adapter",
mode: "spa",
},
}),
react(),
Expand All @@ -44,6 +45,7 @@ All `fsRoutes` fields are required, so each choice is explicit in your config:
- `dir` — the directory scanned for route files (commonly `./src/pages`).
- `root` — the HTML shell component (`<html>…<body>{children}</body></html>`).
- `adapter` — a module that `export default`s the adapter defining the directory / file-name convention. The bundled `@funstack/static/fs-routes/next-adapter` provides the built-in Next.js-like convention with default options; see [Custom Conventions](#custom-conventions-adapters) to configure it or write your own.
- `mode` — `"spa"` for a single-page application with soft client-side navigation, or `"mpa"` for a multi-page application where every link loads a new document. See [SPA or MPA](#spa-or-mpa).

`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes.

Expand Down Expand Up @@ -140,8 +142,30 @@ export function generateStaticParams() {
export { default } from "./_page"; // _page.tsx is marked "use client"
```

## SPA or MPA

Every route becomes its own HTML file either way, so any page can be loaded directly, and the site works on any static host. The required `mode` option decides what happens when the visitor follows a link _within_ the site:

```typescript
funstackStatic({
fsRoutes: {
dir: "./src/pages",
root: "./src/root.tsx",
adapter: "@funstack/static/fs-routes/next-adapter",
mode: "mpa", // or "spa"
},
});
```

- **`"spa"`** — pages are rendered with FUNSTACK Router's [`<Router>`](https://router.funstack.work/api/components). Clicking a link to another page is intercepted and handled as a **soft client-side navigation**: the document stays, layouts shared with the destination keep their state, and only the changed part of the page is rendered. The build pre-renders the Server Component output of every page as static RSC chunks that the client fetches on navigation (see [Params on Client-Side Navigation](#params-on-client-side-navigation) for the details).
- **`"mpa"`** — pages are rendered with FUNSTACK Router's [`<RouteDisplay>`](https://router.funstack.work/api/components), which never intercepts navigations. Clicking a link performs a regular **full page load** of the destination's own HTML, like a classic multi-page site; nothing is fetched on navigation, and the build emits only each page's own RSC payload. Pick this when you do not want client-side routing — for example for a content site where each page should stand alone, or to keep the behavior of plain HTML links.

Pages and layouts are written the same way in both modes: a Client Component always receives the params of the URL being shown, and a Server Component the params it was generated with (which in MPA mode are always the same, since the document never outlives its URL). FUNSTACK Router's hooks that read the location (`useLocation`, `useSearchParams`, `useRouteParams`, …) work in both modes, but in MPA mode features that depend on intercepting navigations are inert — `useBlocker` never blocks, and a setter that changes the URL (such as the one from `useSearchParams`) performs a full page navigation.

## Params on Client-Side Navigation

> This section describes the `"spa"` [mode](#spa-or-mpa). In `"mpa"` mode there is no client-side navigation: every page is loaded directly, so `params` always match the URL.

Loading any generated URL directly always renders the correct params, and so does soft client-side navigation between pages of the _same_ dynamic route (say, from `/blog/hello` to `/blog/world`) — through two different mechanisms depending on the kind of the component:

- **Client Components** (pages and layouts marked `"use client"`) are rendered by FUNSTACK Router in the browser, so they receive the **live** params of the URL currently shown.
Expand Down Expand Up @@ -208,6 +232,7 @@ funstackStatic({
dir: "./src/pages",
root: "./src/root.tsx",
adapter: "./src/my-adapter.ts",
mode: "spa",
},
});
```
Expand Down Expand Up @@ -240,7 +265,7 @@ 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.
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. The [`example-fs-routing-mpa`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing-mpa) package shows the same routing in [MPA mode](#spa-or-mpa).

## Fully Custom Routing

Expand Down
25 changes: 25 additions & 0 deletions packages/example-fs-routing-mpa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "funstack-static-example-fs-routing-mpa",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@funstack/router": "^1.6.0",
"@funstack/static": "workspace:*",
"@types/node": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "catalog:",
"vite": "catalog:"
}
}
75 changes: 75 additions & 0 deletions packages/example-fs-routing-mpa/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
:root {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #213547;
background-color: #ffffff;
}

@media (prefers-color-scheme: dark) {
:root {
color: #ffffffde;
background-color: #242424;
}

a {
color: #6db3f2;
}
}

body {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}

nav {
padding-bottom: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid #ddd;
}

@media (prefers-color-scheme: dark) {
nav {
border-bottom-color: #444;
}
}

nav a {
margin: 0 0.25rem;
}

code {
background: #f4f4f4;
padding: 0.15em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}

@media (prefers-color-scheme: dark) {
code {
background: #333;
}
}

table {
border-collapse: collapse;
width: 100%;
}

th,
td {
text-align: left;
vertical-align: top;
padding: 0.4em 0.6em;
border-bottom: 1px solid #ddd;
}

@media (prefers-color-scheme: dark) {
th,
td {
border-bottom-color: #444;
}
}
21 changes: 21 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/about/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default function About() {
return (
<div>
<h1>About</h1>
<p>
This example demonstrates the built-in file-system routing of{" "}
<a href="https://github.com/uhyo/funstack-static">FUNSTACK Static</a>{" "}
configured as a multi-page application (
<code>mode: &quot;mpa&quot;</code>
).
</p>
<p>
Routes are derived from the file structure under <code>src/pages/</code>{" "}
using the Next.js-like adapter and rendered with FUNSTACK Router&apos;s{" "}
<code>&lt;RouteDisplay&gt;</code>. Each generated page is a standalone
document: following a link loads the destination&apos;s HTML, exactly as
a plain HTML link would.
</p>
</div>
);
}
40 changes: 40 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { FsRouteComponentProps } from "@funstack/static/fs-routes";
import { getPost, posts } from "../posts";

// A dynamic route must enumerate its pages at build time. Each returned
// object becomes one pre-rendered page: `/blog/hello`, `/blog/static-generation`, …
export function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }));
}

// A Server Component page, rendered once per params combination at build
// time. In MPA mode the document never outlives its URL, so `params` always
// matches the URL shown: there is no need for the route object and
// `useRouteParams` to read "live" params, as the SPA example does.
export default function BlogPost({
params,
}: FsRouteComponentProps<{ slug: string }>) {
const post = getPost(params.slug);
const index = posts.findIndex((p) => p.slug === params.slug);
const prev = posts[index - 1];
const next = posts[index + 1];

return (
<article>
<h1>{post?.title ?? "Unknown post"}</h1>
<p>
<code>params.slug</code> of this page: <strong>{params.slug}</strong>
</p>
<p>{post?.body}</p>
<p>Follow the links below: each post is loaded as its own document.</p>
<p>
{prev ? <a href={`/blog/${prev.slug}`}>← {prev.title}</a> : null}
{prev && next ? " | " : null}
{next ? <a href={`/blog/${next.slug}`}>{next.title} →</a> : null}
</p>
<p>
<a href="/blog">Back to the blog index</a>
</p>
</article>
);
}
18 changes: 18 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/blog/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Outlet } from "@funstack/router";

// A Server Component layout: `blog/layout.tsx` wraps every page under
// `blog/`. It renders `<Outlet />` where the matched child page should
// appear. In MPA mode the layout is rendered anew with each page's document;
// there is no shared layout instance persisting across navigations.
export default function BlogLayout() {
return (
<section>
<p>
<small>
Everything below is wrapped by <code>pages/blog/layout.tsx</code>.
</small>
</p>
<Outlet />
</section>
);
}
19 changes: 19 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { posts } from "./posts";
import { SortControl } from "./sort-control";

// A Server Component page listing the posts. The sort order comes from the
// URL's query string, which a Client Component reads and changes; see
// `sort-control.tsx` for how that works without client-side routing.
export default function Blog() {
return (
<div>
<h1>Blog</h1>
<p>
This page is at <code>pages/blog/page.tsx</code>, which maps to the{" "}
<code>/blog</code> route. The posts below live at{" "}
<code>pages/blog/[slug]/page.tsx</code>, a dynamic route.
</p>
<SortControl posts={posts} />
</div>
);
}
30 changes: 30 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/blog/posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Shared data for the blog routes. This file is neither `page.tsx` nor
// `layout.tsx`, so the adapter ignores it: helpers can be co-located with
// routes.
export interface Post {
slug: string;
title: string;
body: string;
}

export const posts: Post[] = [
{
slug: "hello",
title: "Hello, World",
body: "The first post. Each post is a page of the dynamic `/blog/:slug` route, pre-rendered at build time.",
},
{
slug: "static-generation",
title: "Static Generation",
body: "A static site can only serve pages that exist at build time, so dynamic routes enumerate their params with generateStaticParams().",
},
{
slug: "full-page-loads",
title: "Full Page Loads",
body: "Following a link between posts loads the destination's own HTML. There is no soft navigation, so no RSC chunk is fetched and nothing from the previous page is kept.",
},
];

export function getPost(slug: string): Post | undefined {
return posts.find((post) => post.slug === slug);
}
34 changes: 34 additions & 0 deletions packages/example-fs-routing-mpa/src/pages/blog/sort-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";
import { useSearchParams } from "@funstack/router";
import type { Post } from "./posts";

// FUNSTACK Router's hooks that read the location work under `<RouteDisplay>`
// just as under `<Router>`. Setters that navigate, such as the one returned by
// `useSearchParams`, perform a full page navigation in MPA mode: changing the
// sort order below reloads `/blog?sort=…`, and this component renders the
// new order from the URL of the freshly loaded document.
export function SortControl({ posts }: { posts: Post[] }) {
const [searchParams, setSearchParams] = useSearchParams();
const descending = searchParams.get("sort") === "desc";
const sorted = descending ? [...posts].reverse() : posts;
return (
<div>
<p>
Order:{" "}
<button
type="button"
onClick={() => setSearchParams({ sort: descending ? "asc" : "desc" })}
>
{descending ? "newest first" : "oldest first"} (switch)
</button>
</p>
<ul>
{sorted.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
Loading
Loading