JORVEL

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?

Untyped useParams() returns Record<string, string>. By the time a typo lands in production it has already cost a refund. createRoute turns "page broken because useriduserId" into a red squiggle in the IDE, and a 404 instead of a render crash at runtime.

Zod example

ts
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).

ts
const r = createRoute({ path: '/orders/:orderId' });
r.match('/orders/42')?.params.orderId;  // string

defineRoutes — 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.

ts
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.

tsx
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.

LibraryWorks out of the box
ZodYes — z.object(...)
ValibotYes — v.object(...)
YupYes — yup.object(...)
ArktypeYes — same parse shape
Plain functionWrap in { parse(x) { return ... } }

Validation failure modes

CallOn 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.

ts
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.

ts
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'):

ts
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:

ts
const m = routes.user.match(new URL(request.url).pathname);
if (!m) return new Response('Not Found', { status: 404 });
// m.params.id is type-narrowed

Co-locate the registry

Put defineRoutes(...) in a top-level file (src/routes.ts) and import it from anywhere — host, remote, SSR handler. Same source of truth, same types.