Typed routes
createRoute binds a path to a validator (Zod, Valibot, Yup, anything with a parse method). You get compile-time types + runtime validation for params and search, plus a typed build() helper so you can never construct a broken URL.
Why bother?
useParams() returns Record<string, string>. By the time a typo lands in production it has already cost a refund. createRoute turns "page broken because userid ≠ userId" into a red squiggle in the IDE, and a 404 instead of a render crash at runtime.Zod example
import { z } from 'zod';
import { createRoute, defineRoutes } from '@jorvel/runtime';
const userRoute = createRoute({
path: '/users/:id',
params: z.object({ id: z.string().uuid() }),
search: z.object({ tab: z.enum(['profile', 'billing']).default('profile') }),
});
export const routes = defineRoutes({ user: userRoute });
// match
const m = userRoute.match('/users/abc-def');
if (m) m.params.id; // typed string (uuid validated)
// build
const url = userRoute.build({ id: 'abc' }, { tab: 'billing' });
// -> '/users/abc?tab=billing'No validator — raw params
Omit params to get Record<string, string>. Useful for prototyping or for paths where the param type is intrinsically a string (slug, opaque ID).
const r = createRoute({ path: '/orders/:orderId' });
r.match('/orders/42')?.params.orderId; // stringdefineRoutes — route registry
Group routes into one object and reference them by key. Build URLs without sprinkling string literals across the codebase — the moment you rename a route or change a param name, TypeScript catches every call site.
import { defineRoutes, createRoute } from '@jorvel/runtime';
import { z } from 'zod';
export const routes = defineRoutes({
home: createRoute({ path: '/' }),
user: createRoute({
path: '/users/:id',
params: z.object({ id: z.string().uuid() }),
search: z.object({ tab: z.enum(['profile', 'billing']).default('profile') }),
}),
invoice: createRoute({
path: '/billing/invoices/:invoiceId',
params: z.object({ invoiceId: z.string().regex(/^inv_\w+$/) }),
}),
});
// Use anywhere
const url = routes.user.build({ id: crypto.randomUUID() }, { tab: 'billing' });
dispatchJorvelNavigate({ to: url });Read params inside a page
Inside the route's component, pull the typed params from the standard useParams hook. The Zod schema becomes the source of truth for the type.
import { useParams } from '@jorvel/runtime';
import type { z } from 'zod';
import { routes } from '@/routes';
type UserParams = z.infer<typeof routes.user['_paramsSchema']>;
export default function UserPage() {
const { id } = useParams<UserParams>(); // 'string' (uuid validated upstream)
return <h1>User {id}</h1>;
}Supported validators
Any object with a parse(input) => T method works. The optional safeParse(input) hook (returns { success: true, data } or { success: false, error }) is used when available for non-throwing match attempts.
| Library | Works out of the box |
|---|---|
| Zod | Yes — z.object(...) |
| Valibot | Yes — v.object(...) |
| Yup | Yes — yup.object(...) |
| Arktype | Yes — same parse shape |
| Plain function | Wrap in { parse(x) { return ... } } |
Validation failure modes
| Call | On invalid input |
|---|---|
route.match(pathname) | Returns null — caller falls through to the next route. |
route.parse(input) | Throws the validator's error (Zod error, Yup error, etc). |
route.safeParse(input) | { success: true, data } or { success: false, error }. |
Custom validator
Any object with a parse(input): T method works — implement safeParse for non-throwing validation if you call match often.
const validator = {
parse: (x: unknown) => {
if (!x || typeof (x as any).id !== 'string') throw new Error('bad params');
return x as { id: string };
},
};
createRoute({ path: '/x/:id', params: validator });Search-param defaults
Use the validator's default mechanism (z.default(), v.optional(..., 'default')) so missing keys come back populated. The default is also written into build() output — if the value matches the default, build omits it from the URL for cleanliness.
const list = createRoute({
path: '/items',
search: z.object({
page: z.coerce.number().default(1),
sort: z.enum(['asc', 'desc']).default('asc'),
}),
});
list.build({}, { page: 1, sort: 'asc' }); // '/items' (defaults dropped)
list.build({}, { page: 2 }); // '/items?page=2'Splat segments
Trailing splats are typed as a string[] (or string joined by / — pick via splat: 'joined'):
const docs = createRoute({
path: '/docs/*',
params: z.object({ wildcard: z.array(z.string()) }),
});
docs.match('/docs/api/runtime/hooks')?.params.wildcard;
// ['api', 'runtime', 'hooks']
// build() encodes each segment but keeps the '/' separators — no %2F
docs.build({ wildcard: ['api', 'runtime', 'hooks'] });
// '/docs/api/runtime/hooks'SSR usage
On the server, call route.match(url.pathname) directly — no React, no hooks. The result feeds renderRouteToString:
const m = routes.user.match(new URL(request.url).pathname);
if (!m) return new Response('Not Found', { status: 404 });
// m.params.id is type-narrowedCo-locate the registry
defineRoutes(...) in a top-level file (src/routes.ts) and import it from anywhere — host, remote, SSR handler. Same source of truth, same types.