Routing & navigation
Declare a route once, then use that same route shape for links, redirects, GET forms, metadata,
guards, and static export. Rename a path and kovo check points at every stale consumer before you
ship.
Declare a route#
A route() couples a literal path, optional param/search schemas, per-route config, and a page
function. From the CRM reference app's parameterized detail route:
// Source: examples/crm/src/interactive-app.tsx
import { route, s } from '@kovojs/server';
export const dealDetailRoute = route('/deals/:id', {
meta: { description: 'CRM deal detail.', title: 'Deal · Atlas CRM' },
params: s.object({ id: s.string() }), // coercion declared once, like FormData
staticPaths: crmStaticDealPaths,
page({ params }: { params: { id: string } }) {
return <DealDetailRegion dealId={params.id} />;
},
layout: PipelineLayout,
stylesheets: crmStylesheets,
});Singleton routes look the same without the param schema. From the commerce app:
// Source: examples/commerce/src/app.tsx
export const commerceHomeRoute = route('/', {
meta: { description: 'Browse products and checkout.', title: 'Kovo Commerce' },
layout: CommerceCartLayout,
page(_context, request: CommerceRouteRequest) {
return <CommerceCartPage request={request} />;
},
stylesheets: commerceStylesheets,
});Routes minted by one app contract are listed once in that contract's closed assembly:
// Source: examples/commerce/src/app.tsx
const app = defineKovo({
auth: () => demoSession,
db: () => db,
});
export default app.assemble({
routes: [commerceHomeRoute, commerceCartRoute, commerceLoginRoute],
mutations: [addToCart, commerceSignIn, commerceSignOut],
});Route matching is static-first at each path segment; two routes that can match the same canonical
request path is a compile error, not a runtime precedence footnote. Trailing slashes normalize to
one canonical path with a 308 before matching, and a page path answers GET/HEAD (other methods
are 405, because mutations own POST under /_m/).
Add route metadata#
meta writes the document head for that route. Static metadata covers the common page-head fields,
including the Open Graph image:
export const productRoute = route('/products/:id', {
meta: {
title: 'Product detail',
description: 'View price, inventory, and shipping windows.',
image: '/images/products/default-card.png',
},
params: s.object({ id: s.string() }),
page: ProductPage,
});When the title or image comes from query data, derive it from the query instead of duplicating a loader in the route:
import { metaFromQuery, route, s } from '@kovojs/server';
export const productRoute = route('/products/:id', {
params: s.object({ id: s.string() }),
meta: metaFromQuery(productQuery, (value) => {
const product = value as { imageUrl: string; name: string; summary: string };
return {
title: `${product.name} · Kovo Shop`,
description: product.summary,
image: product.imageUrl,
};
}),
page: ProductPage,
});meta.image is a URL sink. Kovo scheme-checks it before emitting <meta property="og:image">, so
query-derived image URLs go through the same output rules as authored attributes.
Type the path params#
Path params are extracted from the literal by template-literal types: PathParams<'/deals/:id'>
resolves to 'id'. The compiler proves the params schema matches the literal, the params
argument exists only when the route declares params, and — critically — every link to the route must
supply exactly those params:
// authoring sugar — params demanded by the route's literal type
<Link to="/deals/:id" params={{ id: deal.id }}>
View deal
</Link>Missing or extra params are a compile error. Form values and URL segments arrive as strings, so the
params schema declares coercion once (s.number(), s.string()), exactly like a mutation's input
schema.
Type the search params (the URL coordination channel)#
Search params are the typed URL channel — the canonical way islands coordinate without shared client
state (see interactive islands). Declare a search schema and read it off the
page context, coerced:
export const productsRoute = route('/products', {
search: s.object({ max: s.number().optional() }), // typed URL state, coercion declared once
page({ search }) {
return <ProductGrid maxPrice={search.max} />;
},
});The commerce login route reads next straight off context.search to drive its post-login
redirect (declaring a search schema is the typed form; reading context.search defensively, as
here, is what you do when a param is loosely shaped):
// Source: examples/commerce/src/app.tsx
export const commerceLoginRoute = route('/login', {
page(context) {
const next = typeof context.search.next === 'string' ? context.search.next : '/cart';
return <LoginForm next={next} />;
},
});A GET form is the no-JS-friendly writer of that channel, and its field names are validated against
the same search schema — the identical machinery that checks mutation form fields:
const f = form.get('/products');
<f.Form>
<f.input name="max" type="number" />
</f.Form>;
// ✗ compile error: a field name not in the route's search schemaRun it#
Scaffold one route, then hit both canonical and non-canonical URLs:
curl -i http://localhost:3000/products
curl -i http://localhost:3000/products/The first response is the page itself. The second shows the trailing-slash normalization the route
table owns: a 308 redirect to the canonical path before the page code runs.
Typed links and href()#
<Link> and href() are compile-time sugar. They carry the path's literal type and lower to a plain
<a href> — there is no link runtime, and a bare string href is valid Kovo source:
<Link to="/products/:id" params={{ id: item.productId }} search={{ max: 500 }}>
View
</Link><!-- lowered IR / wire: an ordinary anchor, no client router -->
<a href="/products/p1?max=500">View</a>The reference apps author the lowered form directly when they don't need param substitution — a plain
<a href="/"> for back-links is idiomatic and equally typed:
// crm/src/components/deal-detail.tsx
<a style={dealDetailStyles.backLink} href="/">
Back to pipeline
</a>Residual literal hrefs in emitted IR are validated against the route table at compile time.
Full-origin URLs and an explicit external marker opt out.
redirect() — including POST-redirect-GET#
redirect('/deals/:id', { params }) types the redirect target the same way links do, so the
no-JS POST-redirect-GET path stays inside the type system. A successful no-JS mutation answers 303
with a Location the route table validates, and the next GET renders the updated page:
import { redirect } from '@kovojs/core';
export async function createDeal() {
const created = await db.deals.create(formData);
return redirect('/deals/:id', { params: { id: created.id } });
}Renaming /deals/:id turns this redirect() red exactly like it turns every <Link> red — the
PRG destination is not a magic string.
notFound() — a page outcome, not ad-hoc status#
Returning notFound() from page renders the app's 404 shell with the correct status, so status
codes stay part of the typed surface rather than hand-constructed responses:
// Source: examples/crm/src/interactive-app.tsx
export const dealDetailRoute = route('/deals/:id', {
params: s.object({ id: s.string() }),
page({ params }, req) {
const deal = loadDeal(req.db, params.id);
if (!deal) return notFound(); // → app 404 shell, status 404
return <DealDetailRegion deal={deal} />;
},
});redirect() and notFound() are the two sanctioned non-200 page outcomes in v1. (Routes may also
return respond.file() / respond.stream() for non-HTML 200/304 bodies; those are still ordinary
routes with params, guards, route validation, and the audits applied. See
endpoints and webhooks for raw machine ingress.)
Route guards#
guard: on a route() runs the same combinator chain as mutations before page executes, and
refines req.session identically — so req.session.user is non-null inside the page under authed:
import { guards, route, s } from '@kovojs/server';
interface ProductRequest {
session?: { user?: { id?: string } | null } | null;
}
export const productRoute = route('/products/:id', {
params: s.object({ id: s.string() }),
guard: guards.authed<ProductRequest>(),
page({ params }, req) {
// req.session.user is non-null here, refined by the guard
return <ProductPage id={params.id} owner={req.session.user.id ?? ''} />;
},
});The reference app guards routes with the same combinators it uses on mutations — guards.authed()
for "signed in" and guards.role('admin') for authorization:
import { guards, route } from '@kovojs/server';
export const accountRoute = route('/account', {
guard: guards.authed(),
page(_in, req) {
/* req.session.user typed */
return <main>Account</main>;
},
});
export const adminRoute = route('/admin', {
guard: guards.role('admin'),
page(_in, req) {
/* … */
return <main>Admin</main>;
},
});Guarded pages enroll in the kovo explain unguarded audit — the report of everything reachable
without authentication — alongside mutations and queries. Guard outcomes are fixed so auth stays
typed: an authed failure runs the app's onUnauthenticated handler (default: 303 to the login
route with the original URL as next), and an authenticated-but-unauthorized failure renders the
app's 403 shell. Sessions themselves are a declared s.object schema resolved once by the app's
sessionProvider before any route, query, or mutation guard runs.
The propagation property#
This is the payoff of typing the path. Because every navigation surface — <Link>, href(), GET
form actions, and redirect() — is checked against the route() declarations, renaming a path is a
single edit whose every stale consumer surfaces as a type error:
// rename the route…
export const dealDetailRoute = route('/deals/:dealId', {
/* … */
});
// …and every one of these goes red under `kovo check` until updated:
// <Link to="/deals/:id" params={{ id }}>…</Link> // literal no longer in RouteRegistry
// redirect('/deals/:id', { params: { id } }); // same stale literal
// <a href="/deals/p1">…</a> // KV220: matches no declared routeThere is no broken link to discover in production, and no grep-the-codebase migration — the type system enumerates the work for you. This is the same declare-once → derive-every-surface property that drives queries and mutations, applied to navigation.
Navigation affordances#
The MPA is fast as plain HTML; these layer on top of the same full-document GET as progressive enhancements, never as an app mode:
- Enhanced navigation. The loader may intercept only eligible same-origin, unmodified, GET anchor clicks. It fetches the canonical full document, validates the render-plan version and segment metadata, and morphs only compatible changed segments — preserving focus, scroll, and island state. On any uncertainty (cross-origin, modified click, hash/download/target, version mismatch, parse failure) it falls back to a normal full GET. Safari and Firefox get ordinary navigations either way; there is no blank-screen failure mode.
- View Transitions. Cross-document View Transitions are opt-in per element pair via
view-transition-name; the compiler stamps matching names across route templates and rejects duplicate static names. - Speculation Rules. Opt-in per route via
prefetch: 'conservative' | 'moderate' | false, declared on theroute()object and default off. Auto-prerender has real hazards (analytics firing in prerendered pages, non-idempotent per-user renders), so apps opt in route-by-route where renders are idempotent and cheap.
export const productRoute = route('/products/:id', {
params: s.object({ id: s.string() }),
prefetch: 'conservative', // Speculation Rules config lives on the route
});Credentialed prerender on guarded routes
prefetch: 'moderate' may prerender the route's page, metadata, and queries with the user's
credentials on hover. Guarded, session-dependent, or not-proven-side-effect-free routes must stay on
prefetch: 'conservative' unless you add a named prefetchJustification explaining why the
credentialed prerender is safe:
export const accountOverviewRoute = route('/account', {
guard: guards.authed(),
prefetch: 'moderate',
prefetchJustification: 'Read-only account chrome; no analytics or write effects during render.',
page: AccountOverviewPage,
});Without that justification, kovo check reports KV419.
Only bootstrap code that ships in the always-loaded inline path counts against the 10,500-byte gzip
budget; the deferred runtime implementation of navigation is intentionally outside that cap. None of
it may break bfcache (no unload handlers). Navigation partials are not a v1 protocol: enhanced
navigation uses the full target document as its oracle, and app TSX never authors navigation segment
stamps or persistence policy.
Next#
- Interactive islands & client state — coordinating islands through the typed URL.
- Layouts — nested route chrome, layout queries, guards, and boundaries.
- Mutations & forms — the POST-redirect-GET write side of navigation.
- The kovo CLI — running
kovo explain unguardedandkovo explain page.
Spec & diagnostics
The MPA spine, enhanced navigation, View Transitions, Speculation Rules, and the degradation
contract: SPEC §8. Typed routes, params/search schemas, <Link>/href(), redirect(),
notFound(), guards on routes, static and query-driven metadata, and the propagation property:
SPEC §6.4. The session schema and fixed guard-failure outcomes: SPEC §6.5. The Interaction Ladder
and the typed URL coordination channel: SPEC §7. The request shell, dispatch order, static-first
matching, and static export: SPEC §9.5. OG image URL-sink checking for meta.image and
metaFromQuery(...): SPEC §4.8 and §13.5. A literal href/form action that matches no declared
route is KV220; an ambiguous or duplicate route path is KV228; a route that cannot be
statically exported as L0/L1 is KV229; a duplicate static view-transition name is KV239;
unguarded credentialed prerender with prefetch: 'moderate' is KV419.
API reference: @kovojs/server.