Type-safe routes shared by your server and client, powered by zod (input validation + transforms), @remix-run/route-pattern (URL matching), and alien-middleware (typed middleware chaining). The router output is intended to be used with @hattip/core adapters.
pnpm add rouzer zodEverything is imported directly from rouzer.
// routes.ts
import * as z from 'zod'
import { $type, route } from 'rouzer'
export const helloRoute = route('hello/:name', {
GET: {
query: z.object({
excited: z.optional(z.boolean()),
}),
// The response is only type-checked at compile time.
response: $type<{ message: string }>(),
},
})Supported route methods are GET, POST, PUT, PATCH, DELETE, and ALL.
The following request parts can be validated with Zod:
pathquerybodyheaders
Zod validation happens on both the server and client. Client validation runs
before fetch, so invalid requests fail locally without sending a network
request.
On the server, path, query, and headers values are parsed from strings and
coerced to number and boolean when the schema expects those types. Request
body values are validated as JSON without this coercion step.
Rouzer uses @remix-run/route-pattern for matching and generation. Patterns can include:
- Pathname-only patterns like
blog/:slug(default). - Full URLs with protocol/hostname/port like
https://:store.shopify.com/orders. - Dynamic segments with
:paramnames (valid JS identifiers), including multiple params in one segment likev:major.:minor. - Optional segments wrapped in parentheses, which can be nested like
api(/v:major(.:minor)). - Wildcards with
*name(captured) or*(uncaptured) for multi-segment paths likeassets/*pathorfiles/*. - Query matching with
?to require parameters or exact values likesearch?qorsearch?q=routing.
import { chain, createRouter } from 'rouzer'
import { routes } from './routes'
const middlewares = chain().use(ctx => {
// An example middleware. For more info, see https://github.com/alien-rpc/alien-middleware#readme
return {
db: postgres(ctx.env('POSTGRES_URL')),
}
})
export const handler = createRouter({
debug: process.env.NODE_ENV === 'development',
})
.use(middlewares)
.use(routes, {
helloRoute: {
GET(ctx) {
const message = `Hello, ${ctx.path.name}${
ctx.query.excited ? '!' : '.'
}`
return { message }
},
},
})Handlers can either return a plain JSON-serializable value or a Response.
Returning a Response gives you full control over status, headers, and body.
export const handler = createRouter({
basePath: 'api/',
cors: {
allowOrigins: [
'example.net',
'https://*.example.com',
'*://localhost:3000',
],
},
debug: process.env.NODE_ENV === 'development',
}).use(routes, {
helloRoute: {
GET(ctx) {
const message = `Hello, ${ctx.path.name}${ctx.query.excited ? '!' : '.'}`
return { message }
},
},
})basePathis prepended to every route (leading/trailing slashes are trimmed).- CORS preflight (
OPTIONS) is handled automatically for matched routes. - You can define a route-level
OPTIONShandler to customize preflight responses; if it returns nothing, Rouzer falls back to the built-in preflight response. cors.allowOriginsrestricts preflight requests to a list of origins (default is to allow any origin).- Wildcards are supported for protocol and subdomain; the protocol is optional and defaults to
https.
- Wildcards are supported for protocol and subdomain; the protocol is optional and defaults to
debugadds anX-Route-Nameresponse header for matched routes and includes more specific validation error messages in400responses.ALLhandlers act as a fallback when a route does not define the incoming HTTP method explicitly.- If you rely on
CookieorAuthorizationrequest headers, you must setAccess-Control-Allow-Credentialsin your handler.
import { createClient } from 'rouzer'
import { helloRoute } from './routes'
const client = createClient({ baseURL: '/api/' })
const { message } = await client.json(
helloRoute.GET({ path: { name: 'world' }, query: { excited: true } })
)
// If you want the Response object, use `client.request` instead.
const response = await client.request(
helloRoute.GET({ path: { name: 'world' } })
)
const { message } = await response.json()You can attach headers to every request:
const client = createClient({
baseURL: '/api/',
headers: {
authorization: `Bearer ${token}`,
},
})Per-request headers are merged on top of these defaults.
You can also pass a custom fetch implementation:
const client = createClient({
baseURL: '/api/',
fetch: myFetch,
})By default, client.json() throws for non-2xx responses. If the response body
is JSON, its properties are copied onto the thrown Error.
You can override that behavior with onJsonError:
const client = createClient({
baseURL: '/api/',
onJsonError(response) {
if (response.status === 404) {
return Response.json({ message: 'not found' })
}
return response
},
})Optionally pass your routes map to createClient to get per-route methods on the client:
import * as routes from './routes'
const client = createClient({
baseURL: '/api/',
routes, // <–– Pass the routes
})
// Shorthand methods now available:
await client.fooRoute.GET()
// …same as the longhand:
await client.json(routes.fooRoute.GET())Routes that define a response type will call client.json() under the hood and return the parsed value; routes without one return the raw Response:
// helloRoute has a response schema, so you get the parsed payload
const { message } = await client.helloRoute.GET({
path: { name: 'world' },
})
// imagine pingRoute has no response schema; you get a Response object
const pingResponse = await client.pingRoute.GET({})
const pingText = await pingResponse.text()Rouzer also exports utility types for route inference:
import type {
InferRouteBody,
InferRouteMethodBody,
InferRouteResponse,
} from 'rouzer'
type CreateUserBody = InferRouteBody<typeof createUserRoute.POST>
type CreateUserBody2 = InferRouteMethodBody<typeof createUserRoute, 'POST'>
type HelloResponse = InferRouteResponse<typeof helloRoute.methods.GET>- Declare it in
routes.tswithroute(…)andzodschemas. - Implement the handler in your router assembly with
createRouter(…).use(routes, { … }). - Call it from the client with the generated helper via
client.jsonorclient.request.