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

app/api/posts/route.ts
									
										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

app/api/users/[id]/route.ts
									
										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
app/api/session/route.ts
									
										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

app/api/notes/route.ts
									
										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();
}
									
								
app/api/login/route.ts
									
										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

app/api/session/route.ts
									
										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

app/api/webhooks/stripe/route.ts
									
										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.