Define routes
In this chapter we will learn about the basics of the file-system based router. We will learn how to define routes, how to use parameters and page layouts.
The router can be configured by creating a react-server.config.mjs or react-server.config.json file in the root of your project. The configuration file should export a configuration object and it should include the root path for your routes. You can also specify a public path for your static files. The file-system based router will automatically scaffold the files in the root directory to prepare and build the routing based on your files in the root directory.
export default {
root: "src/pages",
public: "public",
};
Your directories and files specify the routes your application will use. There are built-in conventions in the router which translates the names of directories and files to route paths and route parameters. To create a new route you need to create a file in the root directory.
Route files have to export default a React component which will be used to render the route. By default, pages are server components. If a page starts with the "use client" directive, it becomes a client-only route — navigating to it happens entirely on the client with no server round-trip, and component state is preserved between navigations.
export default function Home() {
return <h1>Home</h1>;
}
If you name your file index or page it will be treated as an index route. Index routes are the default routes for directories. For example if you create a file named index.jsx in the root directory it will be treated as the default route for your application. If you create a file named index.jsx in a directory named about it will be treated as the default route for the about directory.
If you name your file anything other than index or page it will be treated as a named route. For example if you create a file named about.jsx in the root directory it will be treated as a named route with the path /about. If you create a file named about.jsx in a directory named users it will be treated as a named route with the path /users/about.
You can create nested routes by creating directories in the root directory. For example if you create a directory named users in the root directory and create a file named index.jsx in the users directory it will be treated as a nested route with the path /users. If you create a file named about.jsx in the users directory it will be treated as a nested route with the path /users/about.
You can create route parameters by adding a parameter name in brackets to the name of the file. For example if you create a file named [id].jsx in the root directory it will be treated as a named route with the path /[id]. If you create a file named [id].jsx in a directory named users it will be treated as a named route with the path /users/[id]. You can also create nested route parameters by creating nested directories. For example if you create a directory named users in the root directory and create a file named [id].jsx in the users directory it will be treated as a nested route with the path /users/[id]. You will be able to access the route parameter in your component as React props.
export default function User({ id }) {
return <h1>User #{id}</h1>;
}
You can also use multiple route parameters in a single route segment. For example if you create a file named [id]-[name].jsx it will be treated as a route with the path pattern of /[id]-[name] and you will receive both id and name as props in your component.
export default function User({ id, name }) {
return <h1>User #{id} - {name}</h1>;
}
Matcher alias: a parameter bracket can carry an
=aliassuffix — e.g.[id=numeric].page.jsx→/[id=numeric]. The alias binds the segment to a predicate supplied viaexport const matchers, letting you gate the route on the segment's shape before it matches. See Route matchers below. Plain[id]extracts the param without filtering.
You can create route parameters for multiple segments by adding a parameter name in brackets to the name of the file. For example if you create a file named [...id].jsx in the root directory it will be treated as a named route with the path /[...id]. If you create a file named [...id].jsx in a directory named users it will be treated as a named route with the path /users/[...id]. You can also create nested route parameters by creating nested directories. For example if you create a directory named users in the root directory and create a file named [...id].jsx in the users directory it will be treated as a nested route with the path /users/[...id].
The parameter at runtime will be an array of strings. You will be able to access the route parameter in your component as a React prop.
// /[...slug].page.jsx
export default function Page({ slug }) {
return <h1>/{slug.join("/")}</h1>;
}
You can create route parameters for optional segments by adding a parameter name in brackets to the name of the file. For example if you create a file named [[...id]].jsx in the root directory it will be treated as a named route with the path /[[...id]]. If you create a file named [[...id]].jsx in a directory named users it will be treated as a named route with the path /users/[[...id]]. You can also create nested route parameters by creating nested directories. For example if you create a directory named users in the root directory and create a file named [[...id]].jsx in the users directory it will be treated as a nested route with the path /users/[[...id]].
Omit: you can omit any part of the directory or file name by wrapping the part in parentheses. For example if you create a file named
(404).[[...slug]].page.mdxin the root directory it will be treated as a route with the path/[[...slug]]. You can use this to extend the directory/file name with additional information without affecting the route path.
You can create layouts by creating a file including layout.jsx in the file name. The layout file will be used to wrap all the routes in the same directory where the layout file is. You can also create nested layouts by creating a file named layout.jsx in a sub-directory. You can also use omitted parts in the layout file name. For example if you create a file named (root).layout.jsx in the root directory it will be used as the layout for all the routes in the root directory.
Your layout component will receive a children prop which you need to use to render your route components.
export default function Layout({ children }) {
return (
<>
<h1>Layout</h1>
{children}
</>
);
}
Transparent segments are segments that are not rendered in the URL but are used to identify your file for yourself. You can create transparent segments by creating a file named (transparent).page.jsx where (transparent) is the name of your transparent segment and could be anything you want. For example if you create a file named (main).page.jsx in the root directory it will be treated as a route with the path /. If you create a file named (main).page.jsx in a directory named users it will be treated as a route with the path /users. You can also use transparent segments in your directory structure to group your files. For example if you create a file named page.jsx in a directory named (dashboard)/users it will be treated as a route with the path /users.
src - (root).layout.jsx - (root).page.jsx - (dashboard) - users - (users).page.jsx - [userId].page.jsx
You can escape route segments by wrapping the segment in curly braces. For example if you create a file named {sitemap.xml}.server.mjs in the root directory it will be treated as a named route with the path /sitemap.xml.
Example: see the Photos example in the examples directory for a basic example of file-system based routing.
In the file-system based router, you can validate route params and search params at runtime by exporting a validate object from your page file. The runtime picks up this export automatically — no extra configuration needed.
The validate export is an object with optional params and search keys, each set to a schema from any compatible library (Zod, ArkType, Valibot, or anything satisfying the ValidateSchema interface).
pages/user/[id].page.tsximport { z } from "zod";
import { user } from "@lazarv/react-server/routes";
export const validate = {
params: z.object({
id: z.string().regex(/^\d+$/, "ID must be numeric"),
}),
};
export default user.createPage(({ id }) => {
// id is typed as string and guaranteed to match /^\d+$/
return <h1>User {id}</h1>;
});
When validation is present, the generated TypeScript types infer param and search types from the schema — so useParams(), .Link, and createPage all receive the validated types instead of raw strings.
With ArkType:
pages/user/[id].page.tsximport { type } from "arktype";
import { user } from "@lazarv/react-server/routes";
export const validate = {
params: type({ id: /^\d+$/ }),
};
export default user.createPage(({ id }) => {
return <h1>User {id}</h1>;
});
Validating search params:
pages/products.page.tsximport { z } from "zod";
import { products } from "@lazarv/react-server/routes";
export const validate = {
search: z.object({
sort: z.enum(["name", "price", "rating"]).catch("name"),
page: z.coerce.number().int().positive().catch(1),
}),
};
export default products.createPage(() => {
const { sort, page } = products.useSearchParams();
return <div>Sorted by {sort}, page {page}</div>;
});
When validation fails (e.g. /user/abc with the numeric validation above), useParams() returns null and useSearchParams() returns an empty object {}, letting you handle the error in your component or fall through to a fallback route.
Matchers are predicates that gate whether a route matches a URL in the first place. They run during routing, before any page code loads, and they let sibling routes divide the same parameter space by shape.
Use the matcher syntax by adding an =alias suffix inside a parameter bracket in the file name. The alias is the key you'll use in export const matchers:
| Bracket form | Example | Matcher receives |
|---|---|---|
[name=alias] | [id=numeric].page.tsx | string |
[[name=alias]] | [[tab=known]].page.tsx | string |
[...name=alias] | [...slug=nested].page.tsx | string[] |
[[...name=alias]] | [[...path=valid]].page.tsx | string[] |
Export a matchers object from the page (or middleware or API route) whose keys are the aliases used in the path. Each value is a predicate that returns true to accept the URL and false to reject it. Rejected URLs fall through to sibling routes — they're not 404s by themselves.
pages/product/[sku=uppercase].page.tsximport { productSkuUppercase } from "@lazarv/react-server/routes";
export const matchers = productSkuUppercase.createMatchers({
uppercase: (value) => /^[A-Z0-9-]+$/.test(value),
});
export default productSkuUppercase.createPage(({ sku }) => {
return <h1>Product {sku}</h1>;
});
Place a sibling [sku].page.tsx next to it to catch rejected values:
pages/product/[sku].page.tsximport { product } from "@lazarv/react-server/routes";
export default product.createPage(({ sku }) => {
return <h1>Fallback: {sku}</h1>;
});
The generated route names (productSkuUppercase and product) come directly from the derivation rule described in Route naming: a matcher-gated segment contributes both its param name and its alias, while a bare dynamic segment is stripped. No explicit route override needed.
Now /product/ABC-123 renders the matcher-gated page, and /product/abc-123 falls through to the sibling. The runtime tries matcher-gated routes first — more specific routes always rank above less specific siblings at the same path shape.
Catch-all matchers receive arrays. The alias suffix works identically on [...slug] and [[...slug]], but the predicate gets the whole segment array, not a string:
pages/docs/[...slug=nested].page.tsximport { docsSlugNested } from "@lazarv/react-server/routes";
export const matchers = docsSlugNested.createMatchers({
nested: (slug) => slug.length >= 2,
});
export default docsSlugNested.createPage(({ slug }) => {
return <article>{slug.join("/")}</article>;
});
Here /docs/intro falls through (length 1) but /docs/guide/install matches.
Typed aliases. The aliases you can pass to createMatchers are extracted from the route path at type-generation time. A path like [id=numeric] produces { numeric?: (value: string) => boolean }; a path like [...slug=nested] produces { nested?: (value: string[]) => boolean }. Using an alias that isn't in the path is a type error.
Matchers vs validation. They solve different problems:
- Matchers gate routing. A rejection means "this URL is not for this route" — sibling routes and fallbacks get a chance. No page code loads.
- Validation gates the matched route's params. A rejection means "this route matched, but the params don't satisfy the schema" —
useParams()returnsnull, the page still renders and must handle the error.
Use matchers when several sibling routes share the same parameter slot but differ in its shape. Use validation when a single route needs to constrain the already-extracted params before rendering.
Scope. Matchers are honored on pages, outlet pages, middlewares, and API route handlers. Layouts and error/loading/fallback boundaries don't participate in matcher dispatch — they're selected by containment and by the page they wrap.
When you use the file-system based router, the runtime automatically generates a virtual @lazarv/react-server/routes module. This module exports a named route descriptor for every route in your application — derived from your file structure, updated in real time during development, and regenerated on each build.
import { index, about, user, dashboard } from "@lazarv/react-server/routes";
Each exported descriptor is a full RouteDescriptor with all the same capabilities as one created by createRoute:
.Link— typed Link component with compile-time param checking.href(params?)— build a URL pathname from typed params.useParams()— read typed, validated params (works in server and client components).useSearchParams()— read typed, validated search params.SearchParams— route-scoped search param transform boundary
Plus context-aware helper functions for typing your page components:
.createPage(component)— typed page component with validated params as props.createLayout(component)— typed layout withchildrenand branded outlet props.createLoading(component)— typed loading fallback component.createError(component)— typed error boundary component.createFallback(component)— typed fallback component.createMiddleware(handler)— typed middleware with request params.createMatchers(matchers)— typed matcher map (only on routes whose path contains an=aliasbracket). Aliases are extracted from the path; catch-all aliases receivestring[], single aliases receivestring. See Route matchers.
These helpers are identity functions at runtime — they return the component unchanged. They exist purely for TypeScript to infer the correct prop types from your route path, validate export, and outlet structure.
Route names are derived automatically from the file path using camelCase:
| File path | Exported name |
|---|---|
pages/page.tsx or pages/index.tsx | index |
pages/about.tsx | about |
pages/user/[id].page.tsx | user |
pages/user/posts.page.tsx | userPosts |
pages/dashboard/settings.page.tsx | dashboardSettings |
pages/product/[sku=uppercase].page.tsx | productSkuUppercase |
pages/docs/[...slug=nested].page.tsx | docsSlugNested |
Dynamic segments like [id] are stripped from the name. Purely dynamic paths like pages/[id].page.tsx use the parameter name (id) as the route name. When a segment carries a matcher alias (e.g. [sku=uppercase]), both the param name and the alias contribute to the derived name — so a matcher-gated page and its bare sibling get distinct names automatically, without numeric suffixes. Numeric suffixes are still applied as a last-resort fallback only when the derivation produces a true collision.
You can override the auto-derived name by exporting a route constant from your page file:
export const route = "userProfile";
The exported name then becomes userProfile instead of the auto-derived name:
import { userProfile } from "@lazarv/react-server/routes";
Use createPage to get typed props inferred from the route's path params and validate export:
pages/user/[id].page.tsximport { z } from "zod";
import { user } from "@lazarv/react-server/routes";
export const route = "user";
export const validate = {
params: z.object({ id: z.string().regex(/^\d+$/, "ID must be numeric") }),
};
// TypeScript infers: ({ id: string }) => ReactNode
export default user.createPage(({ id }) => {
return <h1>User {id}</h1>;
});
Without validate, the param types come from the path pattern — [id] gives you { id: string }, [...slug] gives you { slug: string[] }. With validate, types are inferred from the schema's output type, which can be narrower (e.g. a regex-constrained string, a number after coercion).
For pages without dynamic params, createPage receives an empty props object:
pages/about.tsximport { about } from "@lazarv/react-server/routes";
export default about.createPage(() => {
return <h1>About</h1>;
});
Use createLayout to get typed children and outlet props. Outlets are created by placing files in @outletName directories under a layout's directory:
pages/
├── dashboard/
│ ├── (dashboard).layout.tsx
│ ├── index.page.tsx
│ ├── @sidebar/
│ │ └── nav.page.tsx
│ └── @content/
│ ├── @content.default.tsx # default content when no route matches
│ └── feed.page.tsx
The layout receives typed outlet props — each outlet is a branded React.ReactElement that TypeScript tracks separately to prevent mixing:
pages/dashboard/(dashboard).layout.tsximport { dashboard } from "@lazarv/react-server/routes";
export default dashboard.createLayout(({ children, sidebar, content }) => {
return (
<div>
<h1>Dashboard</h1>
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
<aside>{sidebar}</aside>
<main>
<div>{content}</div>
<div>{children}</div>
</main>
</div>
</div>
);
});
Nullable outlets: If an outlet directory has a @outletName.default.tsx file, the outlet is always rendered (non-nullable). If there's no default, the outlet is ReactElement | null — TypeScript enforces null checks.
In the example above, content has a default (@content.default.tsx) so it's always a ReactElement. If sidebar had no default, it would be typed as ReactElement | null.
Loading and error components follow the same pattern:
pages/user/[id].loading.tsximport { user } from "@lazarv/react-server/routes";
export default user.createLoading(() => {
return <div>Loading user profile...</div>;
});
pages/user/[id].error.tsximport { user } from "@lazarv/react-server/routes";
export default user.createError(({ error }) => {
return <div>Error loading user: {error.message}</div>;
});
The route descriptors from the routes module work just like createRoute descriptors — use .Link for navigation and .href() for URL building:
pages/(root).layout.tsximport { index, about, user, dashboard } from "@lazarv/react-server/routes";
export default index.createLayout(({ children }) => {
return (
<html lang="en">
<body>
<nav>
<index.Link>Home</index.Link>
<about.Link>About</about.Link>
<user.Link params={{ id: "42" }}>User 42</user.Link>
<dashboard.Link>Dashboard</dashboard.Link>
</nav>
{children}
</body>
</html>
);
});
Routes without dynamic segments (like index, about) don't accept params. Routes with dynamic segments (like user) require them — TypeScript enforces this at compile time.
The runtime writes a react-server-routes.d.ts file into the .react-server directory. This file declares the @lazarv/react-server/routes module with a per-route interface:
// Auto-generated — do not edit manually
declare module "@lazarv/react-server/routes" {
import type { RouteDescriptor, ValidateSchema } from "@lazarv/react-server/router";
// ── /user/[id] ──
interface UserRoute extends RouteDescriptor<"/user/[id]",
typeof import("../pages/user/[id].page").validate extends
{ params: ValidateSchema<infer T> } ? T : { id: string },
Record<string, string>
> {
createPage(
component: (props: /* inferred params type */) => React.ReactNode
): typeof component;
createLoading(
component: () => React.ReactNode
): typeof component;
}
export const user: UserRoute;
}
The generated types include only the helpers that exist for each route — if a route has a page file, it gets createPage; if it has a layout, it gets createLayout with the correct outlet props; and so on. This file is regenerated automatically during development and builds, so it always matches your file structure.
Note: check out the typed-file-router example for a complete working application using the routes module with validation, layouts, outlets, loading states, and client-only routes.
For the programmatic typed routing API (
createRoute,createRouter, schema validation strategies, parse functions, and more), see the typed router page.
When you use the file-system based router with TypeScript, the runtime automatically generates type definitions from your file structure. These types cover:
- All static and dynamic route paths for
Link,navigate,replace, andprefetch - Route parameter types extracted from dynamic segments like
[id]and[...slug] - Available outlet names for
ReactServerComponent
To enable type checking, add the generated types to your TypeScript configuration:
tsconfig.json{
"compilerOptions": {
// ...
},
"include": [/* ... */, ".react-server/**/*.ts"]
}
That's it. The runtime regenerates types automatically during development whenever your file structure changes, and during builds. Your IDE will immediately show autocompletion for all route paths and flag invalid routes at compile time.
For example, given this file structure:
app/
├── page.tsx # /
├── about.tsx # /about
├── user/
│ ├── [id].tsx # /user/:id
│ └── page.tsx # /user
└── posts/
└── [...slug].tsx # /posts/*
The Link component and navigate function will autocomplete /, /about, /user, and /posts as static routes, and provide typed params for /user/[id] and /posts/[...slug].
For the programmatic typed routing API (createRoute, createRouter, typed hooks, schema validation, and more), see typed router.