docs / Runtime
Routing
Two routing modes — file-based under app/ and manual via createRouter — with dynamic segments and catch-alls, nested layouts, loading/error/not-found/offline pages, Link/NavLink navigation, prefetching, guards, and the low-level router API.
Vesk supports two routing modes that share the same matching, navigation, and data layer. **File-based** (default): the structure of your `app/` directory — plus reserved files (`page.vsk`, `layout.vsk`, `loading.vsk`, `error.vsk`, `not-found.vsk`, `offline.vsk`, `network.vsk`, `middleware.ts`) — determines every URL (the compiler scans `app/` via `scanRoutes` in `packages/compiler/src/router.ts` into a route tree). **Manual**: you build the tree yourself with `createRouter` + `defineRoute`/`buildRouteTree` (or a plain `Record<string, Function>` map) and call `router.start()` — no `app/` scan needed. Both modes use the same `Link`/`NavLink` SPA navigation, `useParams`/`useNavigate`/`useRouter`, guards, redirects, prefetching, and `hash`/`offline` options. This page documents file-based first, then manual.
Route conventions
| app/ file | URL route |
|---|---|
| app/page.vsk | / |
| app/about/page.vsk | /about |
| app/about/layout.vsk | layout wrapping /about |
| app/blog/[slug]/page.vsk | /blog/:slug |
| app/docs/[...rest]/page.vsk | /docs/:rest (catch-all) |
| app/(marketing)/page.vsk | / (route group, no URL segment) |
| app/_private/page.vsk | excluded from the router |
- Every directory can carry `page.vsk` (the route itself) and `layout.vsk` (the wrapper that renders the child chain).
- A directory name in square brackets — `[slug]` — is a dynamic segment; under `/blog/[slug]` the page receives `slug` via `useParams()`.
- A directory starting with `[...` — `[...rest]` — is a catch-all; `useParams().rest` holds the decoded, `/`-joined remainder.
- A directory name wrapped in parens — `(marketing)`, `(auth)` — is a route group: it organizes routes but contributes no URL segment.
- Directories starting with `_` are private and never scanned (components and other non-route files belong there).
- Reserved files resolve to the nearest directory up the match chain: a `not-found.vsk` in `app/` covers every unmatched URL, one in `app/blog/` covers `app/blog` only.
When no page matches, the router renders the nearest `not-found.vsk` (falling back to a built-in `404 — Not Found`). Custom `not-found.vsk` receives `{ params, url }`.
Dynamic segments & params
`useParams()` returns the current route's dynamic segments as a record. Values are URL-decoded. The router updates `params` reactively on every navigation, so reads inside `effect()` re-run when the segment changes.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Catch-all segments capture the remaining URL after the matched prefix. For `app/docs/[...rest]/page.vsk`, `/docs/guides/routing/deep` yields `useParams().rest === 'guides/routing/deep'`.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Nested layouts
A `layout.vsk` wraps every route beneath its directory. The router renders the layout chain inside-out and passes each layout `{ children, params }` — render the matched page (and any inner layouts) with `{props.children}`.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Layouts nest by directory depth. `app/layout.vsk` wraps everything, `app/docs/layout.vsk` wraps only `/docs` routes, and `app/docs/layout.vsk`'s `{props.children}` receives the rendered `app/docs/[...rest]/page.vsk`. The `params` prop mirrors `useParams()` at the layout's depth, so a layout for `app/blog/[slug]/` sees `{ slug }`.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
File-based layouts render the page chain through `props.children` — the runtime (`renderMatch` in `packages/runtime/src/router.ts`) passes `{ children, params }` down the layout chain. The `Outlet` component exists in the runtime surface but is not what file-based layouts use; the layout system renders `props.children` directly.
Manual routing
Use manual routing when file conventions are too rigid: custom URL shapes, a non-`app/` host, a test harness, incremental migration, or a pure SPA without a compiler scan. You declare routes in code and start the router yourself. Manual and file-based share identical matching (static / `:param` / `*` catch-all), layout nesting, `Link`/`NavLink` SPA navigation, `useParams`/`useNavigate`/`useRouter`, `beforeEach` guards, `redirect`/`notFound` throws, `prefetch`, `routeDataCache`, `hash`, and `offline`/`network` boundaries — only the tree construction differs (`app/` scan vs `defineRoute`/`buildRouteTree` or a plain map).
- `createRouter(routes, options)` — array form via `buildRouteTree([defineRoute(path, config), …])` OR map form `Record<string, Function>` (`'/post/:slug': () => '<h1/>'`, `'/files/...rest'` catch-all). Renders into `options.container` (default `#root`). The compiled file-based app is just `createFileRouter(tree, options)` — same engine plus `middleware` + chunk loading (`ensureChunk`).
- `defineRoute(path, config)` — one route definition: `{ path, page?, layout?, loading?, error?, notFound?, offline?, network?, children? }`. Returns the node for `buildRouteTree`. `path` is absolute (`'/'`, `'/about/:id'`, `'/docs/*'`).
- `buildRouteTree(definitions)` — normalizes `defineRoute` results into a matchable tree (`fullPath`, `segmentCount`, `isDynamic`/`isCatchAll`, `*` → catch-all). Use it when you declare routes as an array.
- `matchRoute(tree, pathname)` — pure matcher for testing/tooling: returns `{ matchChain, params }` or `null`. `buildTreeFromMap(map)` is the internal equivalent for the `Record` form.
- Layouts in manual mode use the same `{ children, params }` contract as file-based (`{props.children}` renders the inner chain). The `Outlet` placeholder component (`<Outlet/>`) is an alternative imperative target for manual mount points — file-based layouts never use it.
- Manual routes are NOT auto-imported: `import { createRouter, defineRoute, buildRouteTree, matchRoute } from '@vesk/runtime/router'` explicitly. File-based hooks (`Link`, `NavLink`, `useParams`, `redirect`, …) remain auto-imported.
| Manual config field | File-based equivalent | When it renders |
|---|---|---|
| page | page.vsk | The route itself (`{ params, ...data }`) |
| layout | layout.vsk | Wrapper receiving `{ children, params }`, render `{props.children}` |
| loading | loading.vsk | While SPA navigation to the route is in flight (`{ params }`) |
| error | error.vsk | When page/layout throws (`{ error, retry, params, statusCode, stack, url, offline, networkState }`) |
| notFound | not-found.vsk | Unmatched URL or `notFound()` (`{ params, url }`) |
| offline / network | offline.vsk / network.vsk | Connectivity failure (`{ url, params, retry, online, effectiveType, downlink, rtt, saveData }`) |
| children | subdirectory | Nested routes under the parent path |
import { createRouter, defineRoute, buildRouteTree } from '@vesk/runtime/router';
const Home = (props: any) => '<h1>Home</h1>';
const AboutPage = (props: any) => '<p>about</p>';
const DocsLayout = (props: any) => '<aside>nav</aside><main>' + props.children + '</main>';
const tree = buildRouteTree([
defineRoute('/', { page: Home }),
defineRoute('/about/:id', { page: AboutPage }),
// catch-all uses '*' (file-based '[...rest]' maps to ':rest'):
defineRoute('/docs/*', { page: () => '<p>catch-all</p>', layout: DocsLayout }),
// nested children — alternative to flat '/blog/:slug/comments':
defineRoute('/blog/:slug', {
page: () => '<p>post</p>',
children: [defineRoute('/review', { page: () => '<p>review</p>' })],
}),
]);
const router = createRouter(tree, {
container: document.getElementById('root')!,
prefetch: true,
hash: false,
routeDataCache: 0,
});
router.start();
import { createRouter, matchRoute } from '@vesk/runtime/router';
// Keys are patterns: ':param' for dynamic, '...name' for catch-all.
// The router builds the tree internally with buildTreeFromMap.
const routes: Record<string, Function> = {
'/home': () => '<h1>Home</h1>',
'/post/:slug': () => '<h1>Post</h1>',
'/files/...rest': () => '<h1>Files</h1>', // catch-all
};
const router = createRouter(routes, {
container: document.getElementById('root')!,
hash: true, // route lives in #/path — plain #anchor stays native
});
router.start();
// pure matcher — no router needed (testing, tooling):
const m = matchRoute(router.routeTree, '/post/hello');
console.log(m!.params.slug); // 'hello'
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Manual and file-based can coexist: keep most routes file-based (`createFileRouter` from the generated tree) and mount a manual `createRouter` subtree on a prefix (e.g. `/admin/*`) by giving its catch-all node `layout`/`page` from manual definitions. Shared `useRouter()`/`useNavigate()` work across the prefix boundary; guards (`beforeEach`) and `hash` are per-router.
Loading, error, not-found & offline pages
| Reserved file | When it renders | Props |
|---|---|---|
| loading.vsk | While an SPA navigation to the route is in flight | { params } |
| error.vsk | When the matched page or layout throws | { error, retry, params, statusCode, stack, url, offline, networkState } |
| not-found.vsk | Unmatched URL, or `notFound()` thrown | { params, url } |
| offline.vsk | Navigation failed because the client lost connectivity | { url, params, retry, online, effectiveType, downlink, rtt, saveData } |
| network.vsk | Connectivity-aware UI that live re-renders on online/offline/type changes | { url, params, retry, online, effectiveType, downlink, rtt, saveData } |
Each file sits in the same directory as the routes it covers and resolves to the nearest ancestor up the match chain. The `error.vsk` boundary keeps the layout/nav intact — it swaps only the page slot.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Offline and network pages are the router's connectivity experience for SPA navigations that fail due to lost (or degraded) network. Precedence: nearest `offline.vsk` (when offline) → `network.vsk` → the router's `offline` option → `error.vsk` invoked with `offline: true` → the built-in offline panel.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
A network failure never renders the not-found page — it is a connectivity problem, not a missing route. `props.retry()` debounces a re-navigation, and while an offline/network boundary is displayed the router re-renders it on connectivity changes and re-navigates automatically once the browser comes back online.
Navigation components
- `Link { href, class, style, target, rel }` — renders an `<a>` and intercepts clicks for client-side (SPA) navigation. Clicks with meta/ctrl/shift/alt or a non-primary button, `target="_blank"`, and non-route hrefs (`#anchor`, `mailto:`…) fall through to native behavior.
- `NavLink` — everything `Link` does plus active-state styling: `activeClass` (default `'active'`) is applied and `aria-current="page"` set when the current path exactly equals the href, or is a descendant of it (boundary-aware prefix match, `/docs/x` matches `/docs` but not `/docs2`). Pass `ariaCurrent={false}` to skip `aria-current`.
- Plain `<a href="..." no-reload>` — opts an ordinary anchor into SPA navigation with no page reload (the router installs a delegated click listener). `data-no-reload` is equivalent.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component ArticleLink() {
<a href="/docs/routing" no-reload>Routing docs</a>
}
In hash mode (`hash: true`) `Link` renders `#/path` and `#/...` hrefs are SPA-navigated; plain `#anchor` in-page links always stay native.
Prefetching
The router prefetches route data and route-component chunks on hover: a delegated `mouseenter` listener calls `router.prefetch(href)` for any link on the page. Prefetched data is stashed on the matched route node and used by the next navigation to that path; route-data freshness is controlled by the `routeDataCache` option (TTL in ms, `0` = always refetch fresh data on SPA navigation).
- `createRouter(routes, { prefetch: false })` / `createFileRouter(tree, { prefetch: false })` — disable hover prefetching.
- `router.prefetch(path)` or `useRouter().prefetch(path)` — prefetch on demand (e.g. on focus or button hover).
Programmatic navigation
`useNavigate()` returns `navigate(path, opts?)` for imperative client-side navigation: `navigate('/settings')` pushes history, `navigate('/settings', { replace: true })` replaces. `usePathname()`, `useParams()` and `useSearchParams()` are reactive — reads inside `effect()` re-run on change. `useSearchParams()` returns `[URLSearchParams, setter]` where the setter accepts a record or a raw query string and replaces the current URL.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
`useRouter()` returns a facade over the active router with navigation helpers, loading state and reactive route getters:
| Member | Description |
|---|---|
| push(href) | Navigate, pushing history |
| replace(href) | Navigate, replacing history |
| navigate(href, opts?) | Alias of the router's navigate; opts may carry `{ replace }` |
| back() / forward() / go(n) | History traversal |
| refresh() | Re-navigate the current path (replace) |
| prefetch(href) | Warm route data + chunks for a path |
| beforeEach(fn) | Register a navigation guard; returns an unsubscribe function |
| isLoading | True while a navigation is in flight (reactive) |
| progress | 0–100 progress of the in-flight navigation (reactive) |
| error | True when the last navigation finished with an error (reactive) |
| pathname | Reactive current pathname |
| params | Reactive dynamic-segment params for the current route |
| search | Reactive query string, without '?' |
| setSearch(next) | Set the query string (record or raw string), replacing the URL |
| route | Snapshot `{ pathname, params, pattern }` of the matched route, or null before the first navigation |
| canGoBack | True when `window.history` has an entry to go back to |
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Route guards
`router.beforeEach(fn)` / `useRouter().beforeEach(fn)` register navigation guards. Each guard receives `(to, from)` and returns `false` to block the navigation, a path to redirect to, or `undefined`/`void` to allow. Guards may be async (return a Promise). They run for programmatic and `Link`/`NavLink` navigations only — the browser's own back/forward cannot be blocked. Redirect loops are capped at 5 hops. `beforeEach` returns an unsubscribe function.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Redirects
- `redirect(url, status = 302)` — throws a `Redirect`; the router catches it and navigates (with the status honored by the server).
- `permanentRedirect(url)` — throws a `Redirect` with `status = 308`.
- `notFound()` — throws a `NotFoundError`; the router renders the nearest `not-found.vsk`.
- `Redirect` / `NotFoundError` can also be thrown directly (`throw new NotFoundError()`), as in the docs app's `app/docs/[slug]/page.vsk`.
- Server middleware and API route handlers recognize the thrown `Redirect` (`err.name === 'Redirect'`) and turn it into an HTTP redirect response.
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
component FlightDetail() {
const { id } = useParams();
<p class="flight">Flight {id}</p>
}
Router API reference
Low-level reference for the builders used by both modes above. For a guided walk-through see **Manual routing** above. All builders live in `@vesk/runtime/router` and are NOT auto-imported — import them explicitly. (`Link`, `NavLink`, `Outlet` and `useRouter`/`useNavigate`/`useParams`/`usePathname`/`useSearchParams`/`redirect`/`permanentRedirect`/`notFound` remain auto-imported.)
| Export | Signature | Description |
|---|---|---|
| createRouter(routes, opts) | routes: RouteNode[] | Record<string, Function> | Array form via buildRouteTree OR map form (':param', '...rest'); renders into opts.container (default #root) |
| createFileRouter(tree, opts) | tree: RouteNode[] (from scanRoutes) | File-based factory the compiled app uses: adds middleware + ensureChunk lazy loading |
| defineRoute(path, config) | path: string, config: { page?, layout?, loading?, error?, notFound?, offline?, network?, children? } | One route definition; returns { path, ...config } for buildRouteTree |
| buildRouteTree(defs) | defs: RouteNode[] (defineRoute results) | Normalizes to matchable tree (fullPath, segmentCount, isDynamic/isCatchAll for ':'/'*') |
| matchRoute(tree, pathname) | tree: RouteNode[], pathname: string | Pure matcher → { matchChain, params } | null; buildTreeFromMap is the internal map equivalent |
| ensureChunk(url) | url: string | Loads a per-route chunk script once; used internally by createFileRouter |
import { createFileRouter } from '@vesk/runtime/router';
import routes from './routes.generated'; // scanRoutes output, bundled by compiler
const router = createFileRouter(routes, {
container: document.getElementById('root')!,
hydrate: 'viewport',
prefetch: true,
});
router.start();
Router options shared by both factories: `container`, `prefetch` (default `true`), `viewTransitions` (default `false`; wraps SPA swaps in `document.startViewTransition`), `hydrate` (`'full' | 'viewport' | 'idle' | 'interaction'`), `routeDataCache` (freshness TTL in ms, default `0` = always refetch), `hash` (route in `#/path` instead of pathname; `Link` renders `#/path`), and `offline` (fallback component or HTML string). `createFileRouter` additionally accepts `middleware` and a custom `render`. See `packages/runtime/src/router.ts:64` (RouterOptions) and `packages/runtime/src/router-match.ts:164` (RouteNode).
import { matchRoute, buildRouteTree, defineRoute } from '@vesk/runtime/router';
const tree = buildRouteTree([
defineRoute('/blog', { page: () => '<p>index</p>' }),
defineRoute('/blog/:slug', { page: () => '<p>post</p>' }),
defineRoute('/blog/:slug/comments', { page: () => '<p>comments</p>' }),
]);
const m = matchRoute(tree, '/blog/intro-to-vesk/comments');
console.log(m?.params.slug); // 'intro-to-vesk'
`creating a component with <Redirect to="...">` does not exist: `Redirect` is an `Error` subclass, not a component. Throw it via `redirect(url)` / `permanentRedirect(url)` or `new Redirect(url, status)`.