FFUNSTACK Static
DocsAPILearn

Getting Started

IntroductionMigrating from Vite SPA

Learn

React Server ComponentsHow It WorksOptimizing RSC PayloadsUsing lazy() in ServerPrefetching with ActivityFile-System Routing

Advanced

Multiple Entrypoints (SSG)Server-Side Rendering

API Reference

funstackStatic()defer()BuildEntryFunctionEntryDefinition

Help

FAQ

File-System Routing

Experimental. File-system routing is experimental. Its API may change in a minor release and is not yet covered by semantic versioning.

FUNSTACK Static includes built-in file-system routing. Pages discovered in a directory are automatically mapped to routes and rendered with FUNSTACK Router, with one static HTML file generated per route.

The directory / file-name convention is pluggable through an adapter, and a Next.js-like adapter is provided out of the box.

Requirements

File-system routing renders pages with FUNSTACK Router, so @funstack/router must be installed:

npm install @funstack/router

Setup

Enable file-system routing with the fsRoutes option:

// 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",
        adapter: "@funstack/static/fs-routes/next-adapter",
      },
    }),
    react(),
  ],
});

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 defaults 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 to configure it or write your own.

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

Consider enabling ssr. Pages are server components rendered through FUNSTACK Router. ssr is optional and works the same in dev (vite dev), production builds, and preview either way, but enabling it (ssr: true) is recommended for SEO and faster initial load.

The Next.js-like Convention

The built-in adapter follows Next.js App-Router conventions:

| 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.

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.
  • Segment names routes cannot match — param names may only contain letters, digits, _, and $ (so [foo-bar] is rejected); the same param name may appear only once on a route path; and static directory names must not contain URL-pattern characters (:, *, ?, +, parentheses, braces, or backslash).
  • Route modules without a default export — a page.tsx or layout.tsx that does not export default a component would silently render an empty page.

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

// src/pages/page.tsx
export default function Home() {
  return <h1>Home</h1>;
}
// 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:

// src/pages/blog/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "world" }];
}

export default function BlogPost({ params }: { params: { slug: string } }) {
  return <article>Post: {params.slug}</article>;
}

This generates blog/hello.html and blog/world.html. Each page component receives the resolved params as a prop (see Params on Client-Side Navigation for how params behaves when navigating in the browser).

A dynamic route must export generateStaticParams; the build fails otherwise. A static site can only serve pages that were enumerated at build time, so a dynamic route without it would produce no output.

Param values must be non-empty strings that stay within their URL segment: a regular param value must not contain /, and no value may contain . or .. segments, ?, or #. A catch-all param value may contain / to span multiple segments, but not leading, trailing, or repeated slashes. The build fails on any other value, since it would generate a page that its own route can never match (or a file outside the output directory).

If generateStaticParams returns the same params more than once, the duplicates are collapsed and the page is generated once. However, if two different pages generate the same URL — such as a static blog/hello/page.tsx next to a blog/[slug]/page.tsx whose generateStaticParams also returns { slug: "hello" } — the build fails: the two pages would fight over one output file, and route precedence makes one of them unreachable.

During development, the site's route enumeration — including every generateStaticParams call — runs once and is cached, rather than on every request. Editing any file under the routes directory refreshes the cache automatically. If generateStaticParams derives its result from external data (say, a CMS), pages added to that data appear after you edit a routed file or restart the dev server.

Because generateStaticParams runs on the server at build time, a page module that exports it cannot be marked "use client". If the page body needs to be a Client Component, move it into a separate "use client" module and re-export it from the page:

// src/pages/blog/[slug]/page.tsx (a Server Component)
export function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "world" }];
}
export { default } from "./_page"; // _page.tsx is marked "use client"

Params on Client-Side Navigation

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.
  • Server Components are pre-rendered at build time, once per params combination enumerated by generateStaticParams. On soft navigation, the destination's pre-rendered output is fetched as a static RSC chunk and swapped in, so the params prop — and the entire rendered output — matches the URL shown. The output for the params a chunk covers is shared: navigating between pages under the same Server Component layout re-uses the layout's chunk instead of fetching it again.

Because a Server Component's output is pre-rendered per params combination, a Server Component layout receives only the params of the dynamic segments at or above it — a layout at [lang]/ sees { lang }, not the { slug } of a page below it. (Client Component layouts receive the router's live params of the current match.)

Soft navigation can only render params combinations that were statically generated. If a navigation targets a combination that was never generated — or a chunk fetch fails, for example because a new deployment replaced the build output — the router falls back to a full page load of the destination URL.

Reading Live Params with the Route Object

Every page and layout receives a route prop: an opaque route object identifying its route. In a Client Component, hand it to FUNSTACK Router's useRouteParams hook to read the live params of the current URL. A Server Component cannot use hooks itself, but can forward the prop to a Client Component:

// src/pages/blog/[slug]/page.tsx (a Server Component)
import type { FsRouteComponentProps } from "@funstack/static/fs-routes";
import { LiveSlug } from "./live-slug";

export function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "world" }];
}

export default function BlogPost({
  params,
  route,
}: FsRouteComponentProps<{ slug: string }>) {
  return (
    <article>
      Post generated for: {params.slug}
      <LiveSlug route={route} />
    </article>
  );
}
// src/pages/blog/[slug]/live-slug.tsx
"use client";
import { useRouteParams } from "@funstack/router";
import type { FsRouteObject } from "@funstack/static/fs-routes";

export function LiveSlug({
  route,
}: {
  route: FsRouteObject<{ slug: string }>;
}) {
  const params = useRouteParams(route); // params of the URL currently shown
  return <p>Now viewing: {params.slug}</p>;
}

The FsRouteComponentProps<Params> helper types the params and route props any page or layout receives. Like the params prop itself, the Params type argument is declared by you and not verified against the route's path.

The route prop reaches the two kinds of components differently, with the same result: FUNSTACK Static passes it to Server Components at build time, while FUNSTACK Router (v1.4.0 or later) passes it to Client Components at render time — along with the live params and its other route component props.

Custom Conventions (Adapters)

The convention is defined by an adapter implementing FsRoutesAdapter. The built-in @funstack/static/fs-routes/next-adapter is the Next.js-like adapter with default options; to use a different convention, point adapter at your own module that export defaults an adapter:

// vite.config.ts
funstackStatic({
  fsRoutes: {
    dir: "./src/pages",
    root: "./src/root.tsx",
    adapter: "./src/my-adapter.ts",
  },
});
// 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:

// 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 package in the FUNSTACK Static repository.

Fully Custom Routing

fsRoutes is a convenience built on the entries option. If you need full control, you can write the entries module by hand — globbing pages with import.meta.glob and deriving entry definitions yourself. The @funstack/static/fs-routes building blocks (createFsRoutesEntries, nextRoutes) are exported for this purpose.

When calling createFsRoutesEntries directly, pass the routes directory as the required base option — the directory your glob pattern starts with (e.g. base: "./pages" for import.meta.glob("./pages/**/*.tsx")). File paths are made relative to base to derive the routes, and the build fails if it does not prefix every globbed file.

See Also

  • funstackStatic() - The fsRoutes plugin option
  • Multiple Entrypoints - Generating multiple HTML pages from a single project
  • EntryDefinition - API reference for entry definitions
  • How It Works - Overall FUNSTACK Static architecture