It always starts with a string. const { id } = useParams(). And right there, I'm already annoyed. What is id? It's a string. But my database says it's a number. So now I have to parse it. parseInt(id, 10). But wait, what if it's undefined? What if the user typed "banana" into the URL bar? Now I'm writing isNaN checks inside my component. I'm doing validation logic inside my view layer. Again. Why are we still doing this in 2026?

I look around at the ecosystem and everything is... loud. Everyone is shouting about Server Side Rendering. Hydration. Edge functions. File-based routing where the file system is the API. And I get it, I do, SEO is important, performance is key. But sometimes I just want to build an app. A real, rich, thick-client Single Page App. I don't want a server process watching my files. I don't want a build step that generates a 5,000 line routes.d.ts file that I have to gitignore (or worse, commit). I just want TypeScript to know what my URL is.

I remember looking at Servant. It's this Haskell web framework that treats APIs differently. In Servant, you don't write a route handler and then hope it matches the URL. You define the API as a Type. The type is the spec. The implementation is just a function that has to satisfy that type. If you change the type, the compiler screams at you until you fix the implementation. In Servant, that same route looks like this:

type UserAPI = "users" :> Capture "userId" Int :> "posts" :> Capture "postId" String :> Get '[JSON] Post

It was beautiful. It was strict. It was... correct. It inspired me to ask: Why can't I have that in React?

So I started hacking. I didn't want "paths" like /users/:id. That's just a string with a colon in it. It's weak. I wanted a schema. I wanted to say: "This route consists of the static segment 'users', followed by a Number, followed by 'posts', followed by a String." I wanted this:

path: ["users", number(), "posts", string()]

That's not a string. That's a tuple. That's data. And because it's data, I can infer things from it.

The "Magic": Type Inference Without Codegen

But how do you get TypeScript to understand that number() in the array means the prop passed to your component should be a number? This is where I went down the rabbit hole. Recursive types. I spent nights staring at infer keywords until my eyes blurred. I wanted to avoid code generation at all costs. I wanted the IDE to just know. I ended up with something like this. It looks scary, but it's actually kind of elegant if you squint:

type Params<Arr, Acc extends Array<any> = []> = Arr extends readonly [
	infer Head,
	...infer Tail,
]
	? Head extends BaseSchema<any, any, any>
	? Params<Tail, [...Acc, InferOutput<Head>]>
	: Params<Tail, Acc>
	: Acc;

It walks the array. Is this item a Schema? Yes? Extract its type. No? Ignore it. And suddenly, it worked. I could hover over props.params in my component and see [number, string]. No any. No string | undefined. Just the raw, validated types. It felt like magic, but it wasn't. It was just inference.

The best part? It's basically OpenAPI for your internal routes. You define the contract first. "This page requires a number." The router enforces it. If the URL doesn't match the schema (like if someone types /users/banana), the router doesn't even try to render the component. It blocks it at the door. My components became pure. They didn't have to worry about parsing or validation anymore. They just received data.

I know, I know. "Another router?" But this one feels different. It feels like what we should have had all along if we hadn't gotten so distracted by making the server do everything. It treats the URL as a source of truth, not just a string of characters.

I built this because I was tired of guessing. I was tired of as string. I was tired of runtime errors that should have been compile-time errors. I built it because I wanted to trust my code again.

How it works

So, how do you actually use this thing? It's a two-step process: define the schema, then implement the router. First, you define your routes. This is your contract.

import * as v from "valibot";

// 1. Define your custom types
// The router works with ANY Valibot schema.
// Want a number from the URL? Transform the string.
let Num = v.pipe(
	v.string(),
	v.transform((input) => Number.parseInt(input, 10)),
);

// Want a UUID? Validate it.
let Uuid = v.pipe(v.string(), v.uuid());

// 2. Define your routes
let todoConfig = {
	app: {
		path: ["/"],
		children: {
			home: ["home"],
			// A route with search params for filtering
			todos: {
				path: ["todos"],
				searchParams: v.object({
					filter: v.optional(v.enum(["active", "completed"])),
				}),
			},
			// A route with a UUID path parameter
			todo: ["todo/", Uuid],
			// A route with a Number path parameter (e.g. /archive/2023)
			archive: ["archive/", Num],
		},
	},
} as const;

Then, you create the router. This is where the magic happens. The createRouter function takes your config and forces you to provide components that match the schema.

import { createRouter } from "werkbank/router";

let routerConfig = createRouter(todoConfig, {
	app: {
		// The parent component receives 'children' - this is your Outlet!
		component: ({ children }) => <main>{children}</main>,
		children: {
			home: {
				component: () => <div>Home</div>,
			},
			todos: {
				// Supports Code Splitting!
				// Just pass a dynamic import as 'preload' and a Lazy component
				component: React.lazy(() => import("./TodosList")),
				preload: () => import("./TodosList"),
			},
			todo: {
				// params is inferred as [string] automatically!
				component: ({ params }) => {
					return <h1>Todo: {params[0]}</h1>;
				},
			},
			archive: {
				// params is inferred as [number] automatically!
				component: ({ params }) => {
					return <h1>Archive Year: {params[0]}</h1>;
				},
			},
		},
	},
});

A Note on Tuples vs. Named Parameters: You might notice params[0]. "Wait, I have to access params by index? That's fragile!" Actually, it's the opposite. It's robust. By using tuples, we get instant, zero-config type inference without a build step. If I were to swap the order in the schema—putting the string before the number—TypeScript would immediately flag every usage of params[0] in my component. It would say: "Type 'string' is not assignable to type 'number'". It's strict, but it's safe. It forces you to respect the order of data as defined in your source of truth.

And finally, you render it:

import { Router } from "werkbank/router";

function App() {
    return <Router config={routerConfig} />;
}

Links are also type-safe. You can't link to a route that doesn't exist, and you can't forget a parameter.

import { createLinks } from "werkbank/router";

let links = createLinks(todoConfig);

// This works
<Link href={links.app().todos({ searchParams: { filter: "active" } })}>Active Todos</Link>

// This works (Type-safe UUID!)
<Link href={links.app().todo({ params: ["550e8400-e29b-41d4-a716-446655440000"] })}>View Todo</Link>

// This errors at compile time! (Missing params)
<Link href={links.app().todo()}>View Todo</Link>

Is the verbosity worth it?

I hear you. <Link href={links.app().todo(...)}> is longer than <Link to="/todo/123">. But ask yourself: how many times have you broken a link because you renamed a route? Or forgot a required query parameter? With this approach, a route rename is a simple "Rename Symbol" in your IDE. Every link updates automatically. The verbosity buys you confidence.

Getting Started

TODO: Link to Getting Started Guide

Disclaimer

This is a Proof of Concept (PoC). It's running in my personal workspace. It's not on GitHub yet. It's not battle-tested in a million-user app. It's an experiment in "What if?". I plan to open source this properly at a later date, once I've ironed out the kinks and written some actual documentation. But for now, I just wanted to share the idea. Because sometimes, the best way to move forward is to look at what we're doing and ask: "Does it have to be this hard?"