Using Davaux as an API Server
Davaux's routing and middleware system works independently of its JSX and SSR capabilities. If you need a typed API backend with file-based routing and zero configuration, Davaux works just as well as a pure API server as it does for full-stack apps — and you can always add server-rendered pages later without migrating to a different framework.
Project structure
An API-only project drops islands, layouts, and JSX pages. Everything else stays the same:
src/
middleware.ts # CORS, auth, logging — runs on every request
routes/
api/
_middleware.ts # bearer token auth scoped to /api/*
users.get.ts # GET /api/users
users.post.ts # POST /api/users
users/
[id].get.ts # GET /api/users/:id
[id].patch.ts # PATCH /api/users/:id
[id].delete.ts # DELETE /api/users/:id
No special configuration is needed — Davaux simply won't render any HTML if there are no page routes.
Defining routes
Each file exports a defineHandler. Return any JSON-serializable value and Davaux sends it as application/json. Set ctx.res.statusCode for non-200 statuses.
// src/routes/api/users.get.ts
import { defineHandler } from 'davaux'
import { db } from '~/lib/db.ts'
export default defineHandler(async (ctx) => {
const users = await db.users.findAll()
return users // serialized as JSON automatically
})
// src/routes/api/users.post.ts
import { defineHandler } from 'davaux'
import { z } from 'zod'
import { db } from '~/lib/db.ts'
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'member']).default('member'),
})
export default defineHandler(async (ctx) => {
// Pass the schema directly — Davaux calls schema.parse() and handles errors
const input = await ctx.json(CreateUserSchema)
const user = await db.users.create(input)
return Response.json(user, { status: 201 })
})
When validation fails, Davaux automatically returns 400 Bad Request with the error details — no try/catch needed.
Route parameters
Wrap a path segment in brackets to capture it as a typed param:
// src/routes/api/users/[id].get.ts
import { defineHandler } from 'davaux'
import type { ExtractParams } from 'davaux'
import { db } from '~/lib/db.ts'
type Params = ExtractParams<'api/users/[id]'>
export default defineHandler(async (ctx) => {
const { id } = ctx.params as Params
const user = await db.users.findById(id)
if (!user) {
ctx.res.statusCode = 404
return { error: 'Not found' }
}
return user
})
// src/routes/api/users/[id].patch.ts
import { defineHandler } from 'davaux'
import type { ExtractParams } from 'davaux'
import { z } from 'zod'
type Params = ExtractParams<'api/users/[id]'>
const UpdateSchema = z.object({
name: z.string().min(1).optional(),
role: z.enum(['admin', 'member']).optional(),
})
export default defineHandler(async (ctx) => {
const { id } = ctx.params as Params
const input = await ctx.json(UpdateSchema)
return await db.users.update(id, input)
})
Query strings
ctx.query is a URLSearchParams instance — use it for pagination, filtering, and search:
// GET /api/users?page=2&limit=20&role=admin
export default defineHandler(async (ctx) => {
const page = Number(ctx.query.get('page') ?? '1')
const limit = Number(ctx.query.get('limit') ?? '20')
const role = ctx.query.get('role') ?? undefined
const { users, total } = await db.users.findAll({ page, limit, role })
return {
data: users,
meta: { page, limit, total, pages: Math.ceil(total / limit) },
}
})
Scoped middleware — authenticating an entire prefix
A _middleware.ts inside src/routes/api/ runs before every handler under /api/* without touching any page routes you might add later:
// src/routes/api/_middleware.ts
import { defineMiddleware } from 'davaux'
export default defineMiddleware(async (ctx, next) => {
const auth = ctx.req.headers['authorization']
if (!auth?.startsWith('Bearer ')) {
ctx.res.writeHead(401, { 'Content-Type': 'application/json' })
ctx.res.end(JSON.stringify({ error: 'Unauthorized' }))
return // do not call next()
}
const token = auth.slice(7)
const payload = await verifyToken(token)
if (!payload) {
ctx.res.writeHead(401, { 'Content-Type': 'application/json' })
ctx.res.end(JSON.stringify({ error: 'Invalid token' }))
return
}
ctx.state.apiUser = payload
return next()
})
Augment State for full autocomplete in every handler downstream:
// src/types.d.ts
import 'davaux'
declare module 'davaux' {
interface State {
apiUser?: { sub: string; role: string; scopes: string[] }
}
}
App-level middleware — CORS and logging
Infrastructure concerns that apply to every request live in src/middleware.ts:
// src/middleware.ts
import { defineMiddleware } from 'davaux'
import { cors } from '@davaux/cors'
import { logger } from '@davaux/logger'
import { rateLimit } from '@davaux/rate-limit'
const corsMw = cors({ origin: process.env.ALLOWED_ORIGIN ?? '*', credentials: true })
const logMw = logger({ format: 'json', ignore: ['/_davaux'] })
const limitMw = rateLimit({ windowMs: 60_000, max: 200 })
export default defineMiddleware((ctx, next) =>
logMw(ctx, () => corsMw(ctx, () => limitMw(ctx, next)))
)
Status codes and web Response objects
The straightforward approach — set ctx.res.statusCode before returning:
ctx.res.statusCode = 201
return createdResource
ctx.res.statusCode = 404
return { error: 'Not found' }
Davaux also accepts a standard web Response object as the return value. It extracts the status, headers, and body from the Response and writes them to the underlying Node.js response. This is handy when you want to set status and body in one expression, or when you're porting code written against the fetch API:
// 201 Created with JSON body
return Response.json({ id: user.id }, { status: 201 })
// 404 with JSON error
return Response.json({ error: 'Not found' }, { status: 404 })
// 204 No Content
return new Response(null, { status: 204 })
// Custom content-type and status
return new Response(csvData, {
status: 200,
headers: { 'Content-Type': 'text/csv; charset=utf-8' },
})
Both styles work — choose whichever reads more clearly at the call site. ctx.res.statusCode is useful when the body is computed separately; Response.json() is concise for one-liners.
Non-JSON responses — ctx.send()
For XML, CSV, plain text, RSS feeds, and any format that isn't JSON, use ctx.send(body, contentType, status?) instead of returning a value. It writes the response immediately and skips Davaux's JSON serialization:
// src/routes/blog/rss.xml.get.ts
import { defineHandler } from 'davaux'
export default defineHandler((ctx) => {
const xml = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<!-- ... -->
</channel>
</rss>`
ctx.send(xml, 'application/xml; charset=utf-8')
})
// CSV export
export default defineHandler(async (ctx) => {
const rows = await db.reports.export()
const csv = rows.map(r => `${r.id},${r.name}`).join('\n')
ctx.send(csv, 'text/csv; charset=utf-8')
})
ctx.send() accepts a string or Buffer as the body. The status defaults to 200; pass a third argument to override.
GraphQL
A GraphQL endpoint is a single POST route. The body is JSON, the response is JSON — a natural fit for defineHandler:
// src/routes/api/graphql.post.ts
import { defineHandler } from 'davaux'
import { graphql, buildSchema } from 'graphql'
const schema = buildSchema(`
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
}
`)
const root = {
user: async ({ id }: { id: string }) => db.users.findById(id),
}
export default defineHandler(async (ctx) => {
const { query, variables, operationName } = await ctx.json()
return await graphql({ schema, source: query, rootValue: root, variableValues: variables, operationName })
})
The /api/_middleware.ts auth guard above applies automatically — no extra wiring needed.
API documentation — @davaux/swagger
@davaux/swagger auto-generates an OpenAPI 3.0 spec from your route files and serves a Swagger UI at /docs. Add it as middleware in src/middleware.ts:
// src/middleware.ts
import { defineMiddleware } from 'davaux'
import { swagger } from '@davaux/swagger'
const swaggerMw = swagger({
routesDir: process.env.NODE_ENV === 'production' ? './dist' : './.davaux/routes',
info: { title: 'My API', version: '1.0.0' },
})
export default defineMiddleware((ctx, next) =>
logMw(ctx, () => corsMw(ctx, () => limitMw(ctx, () => swaggerMw(ctx, next))))
)
Named *Schema exports are picked up automatically if zod-to-json-schema is installed:
// src/routes/api/users.post.ts
import { defineHandler } from 'davaux'
import { z } from 'zod'
export const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'member']).default('member'),
})
export default defineHandler(async (ctx) => {
const input = await ctx.json(CreateUserSchema)
// CreateUserSchema appears as the requestBody in the generated spec
})
See the @davaux/swagger docs for the full options reference, restricting access in production, and consuming the raw /openapi.json spec with external tooling.