Rung 3 — API route: a backend endpoint
You need a backend endpoint.
Drop in a handler
Create any file in src/api/. The file path becomes the URL. Export named
functions for each HTTP method you want to handle:
// src/api/orders.ts
import type { CelsianRequest, CelsianReply } from '@celsian/vura-core';
export const route = { kind: 'serverless' };
export async function GET(req: CelsianRequest, reply: CelsianReply) {
const orders = await db.orders.list({ userId: req.query.userId });
return reply.json(orders);
}
export async function POST(req: CelsianRequest, reply: CelsianReply) {
const order = await db.orders.create(req.parsedBody);
return reply.status(201).json(order);
}
req.parsedBody is the parsed JSON body. req.query is the querystring as
a plain object. req.params has path params for dynamic routes
(src/api/orders/[id].ts → req.params.id).
It is serverless by default
Routes default to kind: 'serverless'. Serverless routes are stateless and
short-lived — the correct target for REST APIs, form handlers, and webhook
receivers. They run in-process in the Node production build and compile to a
standalone Worker entry for Cloudflare Workers.
Validation with Zod
Export a schema object from the route file and Vura validates the request
automatically before your handler runs. Validation failures return a
400 VALIDATION_FAILED response with structured details — you never see
invalid input inside the handler:
// src/api/orders.ts
import { z } from 'zod';
import { defineSchema } from '@celsian/vura-core';
import type { CelsianRequest, CelsianReply } from '@celsian/vura-core';
export const route = { kind: 'serverless' };
export const schema = defineSchema({
body: z.object({
item: z.string().min(1),
qty: z.number().int().positive(),
}),
query: z.object({
page: z.coerce.number().int().positive().default(1),
}),
});
export async function POST(req: CelsianRequest, reply: CelsianReply) {
// req.parsedBody is the validated+typed body ({ item: string; qty: number })
// req.parsedQuery is the validated+coerced query ({ page: number }) —
// z.coerce turned the raw "2" string into a real number.
const { page } = req.parsedQuery as { page: number };
// req.query holds the same validated values (req.query.page === 2).
const order = await db.orders.create(req.parsedBody);
return reply.status(201).json(order);
}
defineSchema is a thin wrapper that infers the Zod output types so
req.parsedBody is the validated, typed body. Query params work the same
way: invalid requests get a 400, and the validated+coerced result is on
req.parsedQuery (numbers from z.coerce arrive as real numbers).
Once a query schema has run, req.query holds those same validated values:
reading the ergonomic property never hands back input that skipped the schema
you declared. req.parsedQuery is the explicitly-typed alias. Routes with no
query schema are unaffected, and req.query there is the raw strings from the
URL, as always. zod is a
peer dependency — install it once per project (npm install zod). Any
Zod-compatible library works.
Calling an API route from a page
There is no generated RPC client. Call routes via fetch and share the
TypeScript types by hand — this is the honest pattern in Vura 0.4:
// src/lib/api.ts — shared types and thin fetch wrappers
export interface Order {
id: string;
item: string;
qty: number;
}
export async function createOrder(body: { item: string; qty: number }): Promise<Order> {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`POST /api/orders: ${res.status}`);
return res.json() as Promise<Order>;
}
Import createOrder from both your pages and any server-side code that
needs it. The types flow without a code-generation step.
Where routes run per adapter
| Target | Serverless route | Hot route |
|---|---|---|
| Node / VPS (default) | in-process | in-process |
| Cloudflare Workers | Worker entry | not supported — use VPS/Fly |
| AWS Lambda | Lambda handler | not supported |
| Fly.io | in-process | in-process |
Hot routes require a persistent process. When vura build detects a hot
route and the Cloudflare adapter is active, it emits a warning and excludes
the hot route from the Worker bundle.