RouteGraph v1.0.0 is out. It's stable, it's typed, it's not going to rewrite your framework of choice into a decorator soup. → Try it in 90 seconds
DocsRoute Files & Validation

Route Files & Validation

RouteConfig

interface RouteConfig {
  description?: string
  tags?: string[]
  deprecated?: boolean
  middleware?: Middleware[]
  request?: {
    params?: ZodType
    query?: ZodType
    body?: ZodType
    headers?: ZodType
  }
  response?: {
    [statusCode: number]: ZodType
  }
}

Every field is optional. An empty {} — or no config export at all — is a valid, unvalidated route.

satisfies, not as

export const config = {
  request: { params: z.object({ id: z.string() }) },
} satisfies RouteConfig

satisfies checks the object against RouteConfig without widening its typeRouteHandler<typeof config> can then infer req.params.id as string, not unknown. as RouteConfig would compile but throw away that inference; a plain : RouteConfig annotation would widen the object to the interface and lose the same information. Use satisfies.

RouteHandler

const handler: RouteHandler<typeof config> = async (req, res) => {
  // req.params, req.query, req.body — typed from `config.request`
}
 
export default handler

The generic reads config’s Zod schemas and produces the exact request shape your handler sees — this is the mechanism that makes req.params.id a string instead of string | undefined, with zero manual typing.

Validation, field by field

Add a schema to any of params, query, body, headers independently — validate only what you need:

export const config = {
  request: {
    body: z.object({ name: z.string().min(1), email: z.string().email() }),
  },
  response: {
    201: z.object({ id: z.string(), name: z.string(), email: z.string() }),
  },
} satisfies RouteConfig

A request that fails validation never reaches your handler. It gets a uniform 400:

{
  "error": "Validation failed",
  "issues": [
    { "field": "body.email", "message": "Invalid email", "code": "invalid_string" }
  ]
}

This shape is identical across all five adapters. Your error-handling code on the client doesn’t need to know which framework is running the server.

Custom error handling

Pass onError where you construct the adapter to override the default 400 shape:

createExpressRouter(graph, {
  onError: (issues, req, res) => {
    res.status(422).json({ code: 'VALIDATION_ERROR', issues })
  },
})

Tags, description, deprecated

export const config = {
  description: 'List all users, optionally filtered by role',
  tags: ['users', 'admin'],
  deprecated: false,
} satisfies RouteConfig

tags group routes in the docs UI. deprecated: true marks a route visually in the docs — it does not remove it or change its runtime behavior.

Middleware on a route

export const config = {
  middleware: [requireAuth, rateLimit({ max: 100 })],
} satisfies RouteConfig

Route-level middleware runs after global (app-level) middleware and before validation — see the middleware chain.

A complete annotated example

routes/users/[id]/GET.ts
import { z } from 'zod'
import type { RouteConfig, RouteHandler } from '@routegraph/core'
 
export const config = {
  description: 'Get a single user by their ID',
  tags: ['users'],
  request: {
    params: z.object({ id: z.string() }),
  },
  response: {
    200: z.object({ id: z.string(), name: z.string(), email: z.string().email() }),
    404: z.object({ error: z.string() }),
  },
} satisfies RouteConfig
 
const handler: RouteHandler<typeof config> = async (req, res) => {
  const user = findUserById(req.params.id)
  if (!user) return res.status(404).json({ error: 'User not found' })
  res.status(200).json(user)
}
 
export default handler