File-Based Routing
Furin maps source files in src/pages to URL paths. Directories create URL segments; index files collapse to their parent path. Route-group directories such as (admin) organize pages without adding a URL segment.
| File | URL |
|---|---|
pages/index.tsx | / |
pages/about.tsx | /about |
pages/blog/index.tsx | /blog |
pages/blog/archive.tsx | /blog/archive |
pages/blog/[slug].tsx | /blog/:slug |
pages/docs/[...path].tsx | /docs/* |
pages/(marketing)/pricing.tsx | /pricing |
pages/(admin)/users/[id].tsx | /users/:id |
Route modules may use .tsx, .ts, .jsx, or .js. Furin rejects duplicate route patterns, including ambiguous dynamic names such as [id].tsx and [slug].tsx in the same directory.
A Page Route
Every page exports a named route terminal, built by defineRoute(id) with the file's id:
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/about")
.config({ mode: "ssg" })
.page(() => <h1>About</h1>);
The chain order is fixed:
defineRoute(id)
→ config()
→ staticParams()? (SSG/ISR only)
→ requestLoader()?
→ loader()?
→ head()?
→ page() or layout()
config() and the terminal page() or layout() are required. Optional stages disappear from the builder once used, preventing duplicate or out-of-order declarations. staticParams() is restricted to SSG/ISR and must precede both loader stages; see Rendering Modes for nested build-time params and lazy parent loader data.
An SSR page cannot declare its own requestLoader(); use its loader() for request data. Shared layouts may still use requestLoader() so their private fields reach nested pages.
Route config checks
Furin never rewrites a route file that has content. When you create an empty route file, or one with only whitespace, the development server writes a starter route into it, with its id and the params of its dynamic segments.
The development server and furin build check every route file for mistakes that TypeScript can't see because they depend on where the file sits in pages:
root.tsxusesdefineRootRoute(), and other route files usedefineRoute(id).defineRoute(id)passes the file's own id, andconfig()has nolayoutoption.defineRoute(id)is followed byconfig()with amode.- A page's own
paramsschema declares every dynamic segment of its path, including the segments its layouts also declare.
Each issue names the file, line and column and gives the code to write:
[furin] src/pages/blog/[slug].tsx:4:34: defineRoute("/posts/[slug]") does not match this file. Its id is "/blog/[slug]":
defineRoute("/blog/[slug]")
In development, the first issue is printed in the terminal and shown in the error overlay until you fix it. furin build and furin typegen fail and list every issue. Files that don't parse are skipped, because the transform already reports syntax errors.
Dynamic Parameters
Filename brackets declare a parameter. Declare it in the page's TypeBox params schema to validate it at runtime and infer it everywhere else; the route config checks report a missing one:
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";
export const route = defineRoute("/blog/[slug]")
.config({
mode: "ssr",
params: t.Object({ slug: t.String({ minLength: 1 }) }),
})
.loader(async ({ params }) => ({ post: await getPost(params.slug) }))
.page(({ params, post }) => <article>{post.title}</article>);
Parameter names must be valid JavaScript-style identifiers. Catch-all routes use Elysia's wildcard key, regardless of the filename label:
export const route = defineRoute("/docs/[...path]")
.config({
mode: "ssr",
params: t.Object({ "*": t.String() }),
})
.page(({ params }) => <p>{params["*"]}</p>);
Validated Search Parameters
The query schema controls both request validation and navigation types:
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";
export const route = defineRoute("/products")
.config({
mode: "ssr",
query: t.Object({
page: t.Optional(t.Numeric({ default: 1, minimum: 1 })),
q: t.Optional(t.String()),
}),
})
.loader(({ query }) => search({ page: query.page, query: query.q }))
.page(({ items, query }) => <Results items={items} page={query.page} />);
Schema defaults are applied before loaders run. Navigation omits values equal to those defaults from the generated URL, keeping canonical links clean.
Furin types the two sides of a query schema separately:
- Navigation input (
searchon<Link>,navigate()andRouteSearch): what a caller may send. Required fields are required, optional or defaulted fields are optional, andsearchitself is required as soon as one field is required. - Validated output (
useSearch(), loader and pagequery,RouteSearchOutput): what the route reads after validation, with defaults applied.
The input side comes from the schema's input type. With Zod or another Standard Schema library, z.number().default(1) is optional in navigation input and a number in the output. TypeBox doesn't record default in its static type, so a TypeBox field with only { default } stays required for navigation. Wrap it in t.Optional(...) so links can omit it. The default still applies at runtime, but the validated type then includes undefined.
Inherited params and query
A child route inherits the params and query schemas of every layout above it. The runtime merges the schemas from root to leaf, and the types follow the same rule: if a child declares a field that a parent also declares, the child's version wins. A layout at /orgs/:org with query: t.Object({ lang: t.String() }) gives its children query.lang in their loaders, heads and pages. <Link> and navigate() then require org and lang for those children too.
Path params are the exception: a page still declares every dynamic segment of its path in its own params schema, including org. The page's schema validates the matched URL, and a segment it omits is rejected by the route config checks and when the route module loads:
export const route = defineRoute("/orgs/[org]/projects/[project]")
.config({
mode: "ssr",
params: t.Object({ org: t.String(), project: t.String() }),
})
.page(({ params, query }) => <Project org={params.org} id={params.project} lang={query.lang} />);
By default, changing path parameters remounts the page, while query and hash changes preserve its state. Use remountDeps to customize this page lifecycle.
Special Files
| File | Role | Creates a URL? |
|---|---|---|
root.tsx | Required document layout | No |
_route.tsx | Layout for its directory | No |
error.tsx | Nearest segment error boundary | No |
not-found.tsx | Nearest segment 404 boundary | No |
Any filename beginning with _ | Non-route module | No |
Only the filename is ignored by the route scanner. If you keep page-local helpers under pages, prefix each helper filename with _; a directory name beginning with _ is not itself a route-group convention.
Layout Hierarchy
Filesystem nesting decides which layouts wrap a page:
pages/root.tsx
└── pages/dashboard/_route.tsx
└── pages/dashboard/settings.tsx
The settings page's parent is the dashboard layout, whose parent is the root route. Each route's id gives TypeScript that chain from the generated route types, so a route never imports its layout. See Nested Layouts.
Route groups
Wrap a directory name in parentheses to create a route group:
src/pages/
├── root.tsx
├── (marketing)/
│ ├── _route.tsx
│ ├── index.tsx → /
│ └── about.tsx → /about
└── (admin)/
├── _route.tsx
└── users/
└── [id].tsx → /users/:id
Groups may appear at any directory level and may be nested. For example, pages/(admin)/(settings)/users/[id].tsx still resolves to /users/:id.
A group's _route.tsx wraps only pages inside that group. Layout loaders, error.tsx, and not-found.tsx keep their normal filesystem scope. Route ids keep the group, such as "/(admin)/users/[id]". The shared pages/root.tsx remains required.
Groups do not create routes or access controls by themselves. Two pages cannot resolve to the same URL: (admin)/about.tsx and (marketing)/about.tsx conflict, as do (admin)/users/[id].tsx and (marketing)/users/[slug].tsx. Furin reports both source paths when rejecting a conflict.
Links and navigation APIs use the public URL, such as /users/:id, while generated types retain the grouped source path for imports. Only directories are groups; a file named (admin).tsx creates the literal route /(admin).
Route Ids
defineRoute(id) takes the route file's id. The parent layout comes from the pages tree, not from an import, so config() has no layout option:
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/board/[boardId]/card/_route")
.config({ mode: "ssr" })
.layout(({ children, params }) => <section data-board={params.boardId}>{children}</section>);
The id is the file's path under pages, without the extension: brackets and route groups stay, a trailing /index is dropped, and layouts end in /_route. For example, board/[boardId]/index.tsx is "/board/[boardId]" and (marketing)/about.tsx is "/(marketing)/about".
In an app mounted with a prefix, the id starts with that prefix. In furin({ pagesDir: "./src/admin", prefix: "/admin" }), users.tsx is "/admin/users", index.tsx is "/admin" and settings/_route.tsx is "/admin/settings/_route". Ids are therefore unique across the apps of a project, and a pages directory serves one mount: mounting it under two prefixes fails at startup and in furin build. Give each mount its own pages directory.
The id is type-only. It looks the file up in the FileRoutes interface of furin-env.d.ts, which gives the route:
- its parent layout's loader data,
paramsandquery; - its path params as strings, so
paramsonly needs to refine them. A layout doesn't declare a segment it doesn't have; - its terminal: a
_routeid only acceptslayout(), any other id onlypage().
A page still declares every dynamic segment in its own params schema, because that schema validates the URL at runtime. The route config checks report a missing segment.
You don't type the id of a new file: create the file empty and the development server writes a starter route with its id. Furin never rewrites a file that has content. After you move or rename a route file, TypeScript reports the stale id once furin-env.d.ts is regenerated, and the route config checks report it with the call to write:
[furin] src/pages/posts/index.tsx:3:34: defineRoute("/blog") does not match this file. Its id is "/posts":
defineRoute("/posts")
Generated Route Types
Furin writes furin-env.d.ts when the development server starts, on every furin build, and on furin typegen. It declares one FileRoutes entry per route file for route ids, read from the pages tree without loading the modules, and one RouteMap entry per route pattern, such as "/blog/:slug". RouteMap types <Link>, useNavigate(), and useSearch():
import { Link } from "@teyik0/furin/link";
<Link to="/blog/:slug" params={{ slug: "hello" }}>
Read the post
</Link>;
The same types drive useSearch from @teyik0/furin/search:
import { useSearch } from "@teyik0/furin/search";
const [search, setSearch] = useSearch("/products");
Query defaults declared in the schema are available in search, but Furin omits them from generated URLs. setSearch({ page: 1 }) navigates to /products when page defaults to 1; setSearch({ page: 2 }) navigates to /products?page=2.
Make sure furin-env.d.ts is included in your app tsconfig.json:
{
"include": ["src/**/*", "furin-env.d.ts"]
}
Development type refresh
While the development server is running, editing a route's params, query,
loader, or config refreshes both the browser route snapshot and its TypeScript
contract without a restart.
furin-env.d.ts keeps route entries as typeof import("./path/to/route").route.
TypeScript therefore reads schema and loader changes directly from the route
module, while config changes that affect generated declarations, such as cache
tags, trigger an atomic rewrite of furin-env.d.ts.
Do not edit furin-env.d.ts, and exclude it from formatters such as Biome or Prettier: Furin rewrites it whenever routes change. Commit it if your team wants route types available before the dev server runs. Otherwise, run furin typegen before type-checking in local or CI workflows.
The output is sorted by file id and route pattern, so regenerating it with the same routes gives a byte-identical file.
furin-env.d.ts is one project-wide declaration. Every mounted furin() instance adds its route files to FileRoutes, under ids that start with its prefix, such as furin({ pagesDir: "./src/admin", prefix: "/admin" }). RouteMap only lists the root-mounted instance's patterns: the pages of a prefixed instance work at runtime, but <Link> doesn't type their URLs.