Server routing
Add a file to routes/ to create an endpoint, and export a handler for each HTTP method. The SDK uses Hono for routing and middleware.
On this pageCreate API routes from files · Write handlers with defineHandler · Add middleware and validation
Route files
This step creates route handlers in routes/**/*.ts. Each file's location sets its URL path:
- routes/
- api/
- hello.ts→ GET|POST /api/hello
- users/
- index.ts→ GET|POST /api/users
- [id].ts→ GET|PUT|DELETE /api/users/:id
- search/
- [...query].ts→ GET /api/search/* (catch-all)
Brackets mark dynamic segments. [id] is a route parameter and [...slug] is a catch-all. Files or directories starting with _ are ignored. Directories wrapped in parentheses like (admin) are route groups. They help organize files without changing the URL.
Environment-only routes
This step limits a route to one environment. Add .dev or .prod before the extension.
routes/
api/health.ts → always
api/debug.dev.ts → development only
api/metrics.prod.ts → production only.dev routes run only in development. .prod routes run in builds, deploys and production previews. A resource used only by .dev routes is not provisioned for production.
Suffixes apply only in routes/, not in pages/, middleware/, crons/, or queues/. void prepare generates types for both environments.
This step handles HTTP methods. Each file exports a named constant per method.
// routes/api/hello.ts → GET /api/hello
import { defineHandler } from 'void';
export const GET = defineHandler((c) => {
return { message: 'Hello!', timestamp: Date.now() };
});A file can export multiple methods:
import { users } from '@schema';
// routes/api/users/index.ts → GET + POST /api/users
import { defineHandler } from 'void';
import { db } from 'void/db';
export const GET = defineHandler(async () => {
return await db.select().from(users);
});
export const POST = defineHandler(async (c) => {
const body = await c.req.json();
await db.insert(users).values({ name: body.name });
return { created: true };
});See Database for queries with D1, PostgreSQL, or MySQL.
defineHandler
The handler gets a Hono Context with typed Cloudflare bindings on c.env. See Cloudflare. The full Hono API works, such as c.json(), c.text() and c.header().
Return values are converted for you:
- object/array/number/boolean → JSON response
string→text/html; charset=utf-8null/undefined→204 No ContentResponse→ returned as-is
Validation
defineHandler.withValidator() validates body, query and params. Derive validators from your Drizzle schema to keep them in sync with the database.
import { users, insertUserSchema } from '@schema';
// routes/api/users/index.ts
import { defineHandler } from 'void';
import { db } from 'void/db';
export const POST = defineHandler.withValidator({
body: insertUserSchema,
})(async (c, { body }) => {
const [created] = await db.insert(users).values(body).returning();
return created;
});See Database: Schema-Derived Validators for how to set up createInsertSchema with column refinements.
Manual validators
This step writes a validator by hand for an endpoint without a table. Use any Standard Schema-compatible library (Valibot, Zod, ArkType, etc.):
import * as v from 'valibot';
import { defineHandler } from 'void';
export const POST = defineHandler.withValidator({
body: v.object({
query: v.pipe(v.string(), v.minLength(1)),
limit: v.optional(v.pipe(v.number(), v.maxValue(100)), 10),
}),
})(async (c, { body }) => {
// body is typed as { query: string; limit: number }
return search(body.query, body.limit);
});Validation errors
Failed validation returns 400 with structured error details:
{
"error": "Validation failed",
"issues": [
{
"slot": "body",
"issues": [{ "message": "Invalid email", "path": "email" }]
}
]
}Install your validator library. No separate Standard Schema package is needed.
Validator schemas also power the typed fetch client, so body, query, and params types are enforced at the call site.
Middleware
The SDK uses Hono middleware. defineMiddleware() types the usual (c, next) function. defineHandler() also accepts Hono middleware directly.
import { defineMiddleware } from 'void';
export const addServerTiming = defineMiddleware(async (c, next) => {
const start = performance.now();
await next();
c.header('Server-Timing', `app;dur=${Math.round(performance.now() - start)}`);
});Any hono/* built-in middleware, response helpers, or third-party Hono ecosystem packages can be used in SDK apps. This step installs hono, needed before you import from hono/*.
npm install honoExample output: npm reports the package added.
import { cors } from 'hono/cors';
const allowDashboard = cors({ origin: 'https://app.example.com' });You can apply middleware globally or per-route.
Global middleware
Global middleware runs on every request. This step adds files to middleware/. Numeric prefixes set the order.
middleware/01.logger.tsmiddleware/02.auth.ts
import { defineMiddleware } from 'void';
export default defineMiddleware(async (c, next) => {
console.log(c.req.method, c.req.path);
await next();
});This step adds a temporary site gate with basicAuth(). The SDK's reserved /__void endpoints stay open for tooling. Read credentials through callbacks.
// env.ts
import { defineEnv, string } from 'void/env';
export default defineEnv({
BASIC_AUTH_USERNAME: string(),
BASIC_AUTH_PASSWORD: string(),
});// middleware/01.basic-auth.ts
import { basicAuth } from 'void';
import { env } from 'void/env';
export default basicAuth({
username: () => env.BASIC_AUTH_USERNAME,
password: () => env.BASIC_AUTH_PASSWORD,
realm: 'Preview',
});Set BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD as local variables for development, and as production secrets before deploy.
For app-specific bypasses such as health checks or public webhooks, compose that logic in your own middleware before calling basicAuth().
Use c.set() to share data with downstream handlers. Augment CloudContextVariables for typed access with c.get(). See Context variables.
Per-route middleware
This step adds middleware to one route. Pass it to defineHandler before the handler.
defineHandler(middleware1, middleware2, handler);Middleware runs in order. Each one can short-circuit by returning a response without next(). It can also change the response after await next(). Both defineMiddleware-wrapped functions and raw Hono MiddlewareHandler functions are accepted.
import { cors } from 'hono/cors';
// routes/api/stats.ts
import { defineHandler } from 'void';
export const GET = defineHandler(cors({ origin: 'https://app.example.com' }), (c) => ({
stats: '...',
}));You can pass up to 5 middleware before the handler. Each position is fully typed.
See the Hono integration guide for more examples.