docs / Runtime
API Routes
Server-side endpoints under app/api/: route conventions, method handler exports, dynamic segments, body parsing, validation and VeskResponse.
API routes provide server-side endpoints for data. They live under `app/api/` as `route.ts` (or `route.js`) files and export HTTP method handlers. The scanner maps folder structure to URL paths: a folder named `[id]` becomes a dynamic segment `:id`, `[...slug]` a catch-all `:slug`, folders starting with `_` are private and skipped, and `(group)` folders are route groups whose segment is dropped from the URL.
Route conventions
| File path | URL |
|---|---|
| app/api/posts/route.ts | /api/posts |
| app/api/hello/route.ts | /api/hello |
| app/api/users/[id]/route.ts | /api/users/:id |
| app/api/blogs/[...slug]/route.ts | /api/blogs/:slug* |
| app/api/(marketing)/prices/route.ts | /api/prices |
Method handlers
export async function GET(request: Request) {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || 10;
const posts = await db.findPosts({ limit });
return Response.json(posts);
}
export async function POST(request: Request) {
const post = await request.json();
const created = await db.insertPost(post);
return Response.json(created, { status: 201 });
}
export async function PATCH(request: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const body = await request.json();
return Response.json(await db.updatePost(id, body));
}
export async function DELETE(request: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
await db.deletePost(id);
return new Response(null, { status: 204 });
}
- Export named functions per HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` (and `HEAD`). `OPTIONS` is generated automatically from the exported set; unknown methods get a `405` with an `Allow` header.
- Handlers are called as `handler(request, ctx)` where `ctx.params` is a `Promise<Record<string, string>>` — await it to read dynamic segments: `const { id } = await ctx.params`.
- Returning anything that is not a `Response` (a plain object or value) is serialized as JSON with status 200 automatically.
- A route module may export `config` (`{ csrf, maxDuration }`), `beforeRequest` and `afterRequest` hook arrays — see Route config & hooks below.
Dynamic segments
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const user = await db.findUser(id);
if (!user) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
return Response.json(user);
}
An `[...slug]` folder is a catch-all: the remainder of the path after the previous segment is captured as one parameter joined with `/` (e.g. `app/api/files/[...path]/route.ts` → `GET /api/files/a/b.md` gives `{ path: 'a/b.md' }`). URL-encoded segment values are decoded automatically.
Returning responses
| Return | Effect |
|---|---|
| Response.json(data, init?) | JSON body with your status/headers |
| new Response(body, { status, headers }) | Arbitrary body (text, html, stream, null for 204) |
| VeskResponse.json(...).setStatus(201).setCookie(...).build() | Fluent response builder |
| VeskResponse.redirect(url, 307) / VeskResponse.next() | Redirect / pass-through responses |
| any non-Response value | Auto-serialized to JSON, status 200 |
import { VeskResponse } from '@vesk/runtime';
export async function GET(request: Request) {
return VeskResponse.json({ ok: true })
.setStatus(200)
.setCookie('session', 'abc123', { httpOnly: true, maxAge: 3600, sameSite: 'Lax' })
.setCsp("default-src 'self'")
.cache(60)
.build();
}
- `VeskResponse` is a fluent `Response` subclass (exported from `@vesk/runtime`). Chain `.setStatus(code)`, `.setCookie(name, value, opts)`, `.clearCookie(name)`, `.cache(ttlSeconds)`, `.noCache()`, `.cors(...)`, `.setCsp(policy)`, `.setSecurityHeader(name, value)` and end with `.build()`. Statics: `json`, `html`, `stream`, `redirect` (default 307), `rewrite`, `next`.
- Cookie options: `httpOnly` (default true), `secure` (default true), `sameSite` (default 'Lax'), `maxAge`, `path`, `domain`.
- Security headers set via `setCsp`/`setSecurityHeader` are flushed by `.build()` (or lazily on `.text()`/`.json()`).
- A thrown `Redirect` error (status + url) or `NotFoundError` is turned into a 30x/404 response by the runtime.
Body parsing & validation
import { VeskResponse, useBody } from '@vesk/runtime';
export async function POST() {
const body = await useBody();
// Parsed by content-type: JSON -> object, x-www-form-urlencoded -> record, otherwise text.
const note = await db.saveNote(body);
return VeskResponse.json(note).setStatus(201).build();
}
import { withValidation } from '@vesk/runtime';
import { z } from 'zod';
const schema = z.object({
email: z.string().min(1, 'Email is required'),
password: z.string().min(8, 'Minimum 8 characters'),
});
export async function POST(request: Request) {
const data = await withValidation(request, schema);
if (data instanceof Response) return data; // 400 { error, issues: [{ path, message }] }
const user = await db.verifyLogin(data.email, data.password);
return Response.json({ ok: Boolean(user) });
}
`withValidation(request, schema, { jsonOnly? })` reads the request body (JSON, form-encoded, or text), runs `schema.safeParse(data)`, and returns either the parsed data or a 400 `ServerResponse.json({ error, issues })` response that you can short-circuit. `jsonOnly: true` forces JSON parsing. Body-reading failures return a 400 `Invalid request body` response.
Cookies, headers, locals
import { VeskResponse, cookies, headers, locals } from '@vesk/runtime';
export async function GET() {
const session = cookies().get('session');
const clientIp = headers().get('x-forwarded-for');
const user = locals().user; // seeded by middleware via ctx.set('user', ...)
return VeskResponse.json({ session, clientIp, user }).build();
}
`cookies()` returns a CookieStore with `get(name)`, `getAll()` and `toString()`; `headers()` a case-insensitive read proxy (`get`/`has`/`entries`); `locals()` the per-request store shared with middleware and the render. `useRequest()` returns the full request context and `useParams()` the current params.
Route config & hooks
export const config = {
csrf: false, // skip the same-origin check (webhooks have no browser origin)
maxDuration: 30, // abort the request after 30 seconds
};
export async function POST(request: Request) {
const event = await request.json();
return Response.json({ received: true });
}
Mutating API calls (anything except GET/HEAD/OPTIONS) are checked for same-origin by default via `assertSameOrigin` — cross-origin requests get a 403. Opt out per route with `export const config = { csrf: false }`. `config.maxDuration` (seconds) aborts the request signal when exceeded. A route can also export `beforeRequest`/`afterRequest` arrays of hooks that may short-circuit or replace the response.