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.

app/flights/[id]/page.vsk
										
											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'`.

app/flights/[id]/page.vsk
										
											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}`.

app/flights/[id]/page.vsk
										
											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 }`.

app/flights/[id]/page.vsk
										
											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
app/client.ts (manual — array form)
									
										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();
									
								
app/client.ts (manual — map shorthand, no buildRouteTree)
									
										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'
									
								
app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/flights/[id]/page.vsk
										
											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.

app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/flights/[id]/page.vsk
										
											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.

app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/flights/[id]/page.vsk
										
											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.

  • `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.
app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/components/ArticleLink.vsk
									
										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.

app/flights/[id]/page.vsk
										
											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
app/flights/[id]/page.vsk
										
											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.

app/flights/[id]/page.vsk
										
											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.
app/flights/[id]/page.vsk
										
											component FlightDetail() {
	const { id } = useParams();
	<p class="flight">Flight {id}</p>
}
										
									
app/flights/[id]/page.vsk
										
											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
app/client.ts (file-based entry, for reference)
									
										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).

app/router-utils.ts (standalone matcher, testing/tooling)
									
										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)`.