File-based routing
Dropping a file creates a route. Conventions for pages, layouts, dynamic params, and 404/error.
In Vantage, where you place a file is its URL. You never configure a router.
Where pages live
There are two layouts, and which one applies is decided by whether src/ exists — detected,
never configured.
| Project | Page-scan root | The file that becomes / |
|---|---|---|
Has src/ |
Inside src/ only |
src/index.tsx |
No src/ |
The project root | index.tsx |
src/ itself never appears in a URL (src/sales/index.tsx → /sales). A one-file app can stay at
the root; once it grows, create src/ and move the pages in.
Page conventions
The “File” column below is relative to the page-scan root above.
| File | Route |
|---|---|
index.tsx |
/ |
monthly-analysis.tsx |
/monthly-analysis |
sales/index.tsx |
/sales |
sales/[customerId].tsx |
/sales/:customerId |
sales/[...path].tsx |
/sales/* (catchall) |
_layout.tsx |
Layout for that directory (nestable) |
_404.tsx |
Not-found UI (root only) |
_error.tsx |
Error UI (root only) |
[name]→ dynamic param,[...name]→ catchall.indexmaps to its own directory (the name drops from the URL).- Files starting with
_are reserved (_layout/_404/_error) or private (not routed).
Directories that are never routed
These directories are never scanned, at any depth. Put whatever code you like here.
components/ Reusable React components
hooks/ Custom hooks
lib/ Utilities
server/ API (its presence enables the server)
public/ Static assets
The three spellings of a dynamic param
The same param appears in three spellings depending on where it is. They must match.
| Where | Spelling | Example |
|---|---|---|
| Filename | [id] |
sales/[customerId].tsx |
| Display route | :id |
/sales/:customerId |
| Link | $id + params |
to="/sales/$customerId" params={{ customerId }} |
import { Link, useParams } from "@squadbase/vantage/router";
function Row({ customerId }: { customerId: string }) {
return (
<Link to="/sales/$customerId" params={{ customerId }}>
Open customer
</Link>
);
}
// sales/[customerId].tsx
export default function Customer() {
const { customerId } = useParams({ strict: false });
return <div>Customer {customerId}</div>;
}
Layouts and nesting
_layout.tsx renders an <Outlet /> that wraps child routes. Place one per directory to nest them.
// _layout.tsx
import { Outlet } from "@squadbase/vantage/router";
export default function RootLayout() {
return (
<div className="min-h-screen">
<nav className="border-b p-4">My Dashboard</nav>
<Outlet />
</div>
);
}
_404.tsx and _error.tsx are recognized at the root only.
Build a nav from the route list
useRoutes() returns every page route in the app. The file system is the source of truth, so adding
one page file adds one nav entry — there is no link array to maintain.
// _layout.tsx
import { Link, Outlet, useCurrentRoute, useRoutes } from "@squadbase/vantage/router";
export default function RootLayout() {
// Dynamic routes (/sales/:customerId) have no single URL, so leave them out.
const routes = useRoutes().filter((route) => !route.dynamic);
const current = useCurrentRoute();
return (
<div>
<nav>
{routes.map((route) => (
<Link key={route.path} to={route.to} aria-current={route.path === current?.path}>
{route.label}
</Link>
))}
</nav>
<Outlet />
</div>
);
}
The order is Vantage’s scan order — shallow before deep, static before dynamic, alphabetical — which is the order a nav wants.
Each entry is a RouteInfo:
| Field | Type | Contents |
|---|---|---|
path |
string |
Display path, /sales/:customerId |
to |
string |
The form Link’s to expects, /sales/$customerId |
params |
string[] |
Dynamic parameter names; a catch-all is _splat |
dynamic |
boolean |
Whether the route has dynamic parameters |
index |
boolean |
Whether it is a directory’s index route |
label |
string |
Display name: navLabel, else title, else path |
title / description / navLabel |
string | undefined |
Values from definePage |
useCurrentRoute() returns the route being rendered, or undefined on the 404 route. Beyond the
“you are here” check above, it fits breadcrumbs and page headings.
Keep filter state in the URL
A dashboard’s filters belong in the URL: the view survives a reload and can be pasted to a colleague.
useSearchParam reads and writes one search-param key with a useState shape.
import { useSearchParam } from "@squadbase/vantage/router";
import { SegmentedControl } from "@squadbase/vantage/components";
export default function Sales() {
const [region, setRegion] = useSearchParam("region", "all");
return <SegmentedControl options={REGIONS} value={region} onChange={setRegion} />;
}
- The value is always a string (
?year=2024parses as a number; this hook normalizes it back). - Writing the default value (
"all"above) ornullremoves the key from the URL. - History defaults to
replace, so filtering doesn’t fill the back button. Pass{ replace: false }to push instead.
For anything JSON-serializable — an array, an object — use useSearchState, which also accepts a
functional update.
import { useSearchState } from "@squadbase/vantage/router";
const [segments, setSegments] = useSearchState<string[]>("segments", []);
setSegments((prev) => [...prev, "enterprise"]);
HMR and route regeneration
Adding or removing a .tsx page or a server/api file regenerates routes and triggers a full
reload. Editing a page body applies in place via React Fast Refresh.
What to read next
- Pages & definePage — the page contract and metadata