Server-side Validation
routar makes the client enforce the endpoint contract. But the endpoint is only half the story: your server still has to validate the incoming request. The common trap is to hand-write a second Zod schema in the Route Handler:
// ❌ the drift trap — a second schema, maintained by hand
const CreateBodySchema = z.object({
title: z.string().min(1),
completed: z.boolean().default(false),
userId: z.number().default(1),
})The moment the endpoint’s request.body changes, this copy is stale — the
client sends the new shape and the server rejects (or silently accepts) it. The
contract and its server implementation have drifted.
createParser closes the gap: it derives the request/response parsers straight
from the same endpoint spec the client uses, so there is exactly one schema.
createParser
createParser(spec) takes an endpoint spec (created by
endpoint()) and returns:
parseRequest({ path?, query?, body? })— present when the spec has arequest. Validates the request envelope, returns the parsed value, throws the original error on invalid input.parseResponse(raw)— always present. Validates against theresponseschema. (It does not run theadapter— that’s client-side.)
It has no HTTP concerns: no status codes, no error formatting. That is what
makes it framework-agnostic. You assemble the { path, query, body } envelope
from your framework’s request object, and you decide what an invalid request
becomes.
Next.js Route Handler example
The demo app defines the todo contract once in remote/services/todo.ts:
export const TodoRouter = defineRouter('/todos', {
getList: endpoint({
method: 'GET',
path: '/',
request: { query: z.object({ userId: z.coerce.number().optional() /* … */ }).optional() },
response: z.array(TodoRawSchema),
adapter: (raw) => raw.map(toTodoItem),
}),
create: endpoint({
method: 'POST',
path: '/',
request: { body: z.object({ title: z.string().min(1), completed: z.boolean().default(false), userId: z.number().default(1) }) },
response: TodoRawSchema,
adapter: toTodoItem,
}),
// getDetail / update / remove …
})The Route Handler pulls its parsers from that router — no second schema:
// app/api/todos/route.ts
import { createParser } from '@routar/core'
import { TodoRouter } from '@/remote/services/todo'
import { badRequestFrom, created, ok } from '../_lib/http'
import { createTodo, getAllTodos } from './_store'
const listParser = createParser(TodoRouter.endpoints.getList)
const createParserFor = createParser(TodoRouter.endpoints.create)
export async function GET(req: NextRequest) {
try {
const { query } = await listParser.parseRequest({
query: Object.fromEntries(req.nextUrl.searchParams.entries()),
})
return ok(getAllTodos(query))
} catch (err) {
return badRequestFrom(err)
}
}
export async function POST(req: NextRequest) {
try {
const { body } = await createParserFor.parseRequest({ body: await req.json() })
return created(createTodo(body))
} catch (err) {
return badRequestFrom(err)
}
}Path params work the same way — the spec’s request.path schema coerces the raw
string id, so there is no manual Number(id):
// app/api/todos/[id]/route.ts
const updateParser = createParser(TodoRouter.endpoints.update)
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
try {
const { path, body } = await updateParser.parseRequest({
path: { id }, // string → coerced to number by the spec
body: await req.json(),
})
const todo = updateTodo(path.id, body)
return todo ? ok(todo) : notFound()
} catch (err) {
return badRequestFrom(err)
}
}Status codes are the app’s job
parseRequest throws the original ZodError; you decide it becomes a 400.
That mapping lives in a tiny app-owned helper, not in routar:
// app/api/_lib/http.ts
import { z } from 'zod'
export function badRequestFrom(err: unknown): NextResponse {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.flatten() }, { status: 400 })
throw err // not a validation error → a real bug, don't swallow it
}Keeping the status mapping out of the parser is deliberate: routar never assumes a transport, so the same parser serves any framework.
Framework-agnostic
Because there are no HTTP concerns baked in, the identical parser works outside Next.js. Only the glue that reads the request and writes the response changes:
// Hono
import { Hono } from 'hono'
const app = new Hono()
app.post('/todos', async (c) => {
const { body } = await createParserFor.parseRequest({ body: await c.req.json() })
return c.json(createTodo(body), 201)
})// Express
app.post('/todos', async (req, res) => {
try {
const { body } = await createParserFor.parseRequest({ body: req.body })
res.status(201).json(createTodo(body))
} catch (err) {
if (err instanceof ZodError) return res.status(400).json({ error: err.flatten() })
throw err
}
})Validating the response too
parseResponse lets the server confirm it produced data that matches the
contract before sending it — the same schema the client will parse. Remember it
returns ValidatorOutput<response> (the raw schema output), not the
adapter output, since the adapter is client-side post-processing.
const detailParser = createParser(TodoRouter.endpoints.getDetail)
const row = await db.todos.find(id)
return ok(await detailParser.parseResponse(row)) // throws if the row breaks the contractSee also
createParser()— full API referenceendpoint()— defines the shared contract- Error Handling — how routar normalizes client-side errors