Build an API endpoint
Two patterns for an API route — a quick filling chain, or a type-safe contract. One prompt covers both.
On this page
Build an API endpoint
TL;DR — Mandu has two API patterns: a filling chain for quick endpoints and a contract + handler pair for type-safe APIs. Both live at
app/api/<route>/route.ts. Pick filling for prototypes, contract for anything you'll commit to.
Why
In Next.js / Express the same job costs 30 lines — zod schema, CSRF check, rate limit, try/catch, status code branching, type assertions. Every change has to be hand-synced. When an agent says "add auth", it has to guess where each piece lives.
In Mandu, the route file is chain-shaped so an agent can extend it (.use(withCsrf()), .guard(...), another .post(...)) without rewriting the body. For shapes you care about, contracts derive types and OpenAPI from a single declaration.
Pattern A · Filling chain (quick)
// app/api/users/route.ts
import { Mandu } from "@mandujs/core";
import { db } from "@/server/db";
export default Mandu.filling()
.guard((ctx) => {
if (!ctx.get("user")) return ctx.unauthorized("Login required");
})
.get(async (ctx) => {
const users = await db.users.findMany();
return ctx.ok({ data: users });
})
.post(async (ctx) => {
const body = await ctx.body<{ name: string }>();
const user = await db.users.create({ data: body });
return ctx.created({ data: user });
});
Chain elements:
.use(middleware)— runs first, e.g..use(withSession()),.use(withCsrf()).guard(fn)— early return short-circuits the chain (returnctx.unauthorized(...),ctx.forbidden(...)).get(fn)/.post(fn)/.put(fn)/.patch(fn)/.delete(fn)— HTTP method handlers- response helpers:
ctx.ok(),ctx.created(),ctx.unauthorized(),ctx.redirect(url, 302)
Pattern B · Contract + handler (type-safe)
// spec/contracts/user.contract.ts
import { Mandu } from "@mandujs/core";
import { z } from "zod";
export const userContract = Mandu.contract({
description: "Users API",
tags: ["users"],
request: {
GET: { query: z.object({ id: z.string() }) },
POST: { body: z.object({ name: z.string() }) },
},
response: {
200: z.object({ data: z.any() }),
400: z.object({ error: z.string() }),
},
});
// app/api/users/route.ts
import { Mandu } from "@mandujs/core";
import { userContract } from "@/spec/contracts/user.contract";
import { db } from "@/server/db";
export default Mandu.handler(userContract, {
GET: async (ctx) => ({ data: await db.users.findUnique({ where: { id: ctx.query.id } }) }),
POST: async (ctx) => ({ data: await db.users.create({ data: ctx.body }) }),
});
Once the contract is in place, Mandu auto-derives:
- TypeScript types —
ctx.query.idisstring,ctx.body.nameisstring - request validation — invalid input returns 400 automatically
- OpenAPI export via
mandu contract openapi - a typed client wrapper (import the contract from any client component and
client(contract)is fully typed)
🤖 Agent Prompt — generate either pattern in one shot
Add a Mandu API route at `app/api/<NAME>/route.ts` for the resource
`<NAME>` (e.g. users, posts, signup).
Pick the pattern based on what I need:
- Pattern A (filling chain) for quick / prototype endpoints:
export default Mandu.filling()
.use(withSession()) // optional middleware
.guard((ctx) => { ... }) // optional auth short-circuit
.get(async (ctx) => ctx.ok({ ... }))
.post(async (ctx) => {
const body = await ctx.body<{ ... }>();
return ctx.created({ ... });
});
- Pattern B (contract + handler) for type-safe + OpenAPI:
// spec/contracts/<NAME>.contract.ts
export const <name>Contract = Mandu.contract({
request: { GET: { query: z.object({...}) },
POST: { body: z.object({...}) } },
response: { 200: z.object({...}),
400: z.object({ error: z.string() }) },
});
// app/api/<NAME>/route.ts
export default Mandu.handler(<name>Contract, {
GET: async (ctx) => ({ ... }),
POST: async (ctx) => ({ ... }),
});
Required invariants:
- File path: app/api/<NAME>/route.ts (default export only)
- Use ctx helpers (ctx.body<T>, ctx.query, ctx.ok, ctx.created,
ctx.unauthorized, ctx.redirect) — never construct a raw Response.
- Middleware chains with .use(...); never inline auth/CSRF in the body.
- Contract path: spec/contracts/<NAME>.contract.ts (named export
ending in `Contract`).
After writing the files, run `bun run guard` and `bun run check`
and report any violations.
A single prompt and your AI agent — Claude Code · Cursor · OpenAI Codex · GitHub Copilot · Gemini CLI — picks the pattern, writes the files, and runs Guard. Just swap <NAME> to reuse.
Pitfalls — invariants you must keep
- Default export only.
app/api/X/route.tsis recognised by itsdefaultexport. Named exports are ignored. - Don't construct
Responsedirectly. Always go throughctx.ok(),ctx.created(),ctx.unauthorized(),ctx.redirect(). Mandu Guard rejects rawnew Response(...)inapp/api/**. - Middleware on the chain, not in the body. Use
.use(withSession())and.use(withCsrf()), notif (!ctx.session) .... - Pattern B contract location is fixed.
spec/contracts/<name>.contract.ts. Anywhere else and OpenAPI extraction breaks. - Don't mix patterns. Either filling chain or
Mandu.handler(contract, {...}). Doing both in the same file confuses the router.
Related
- start/quickstart — start here if you don't have a project yet
- architect/guard — what Guard catches in route files
- recipes/auth — middleware patterns (
withSession,withCsrf) - recipes/testing — testing both filling chains and contracts
For Agents
For quick endpoints, use `Mandu.filling().get(...).post(...)` chain on a default export at `app/api/<route>/route.ts`. For type-safe contracts, define `Mandu.contract({ request: { GET, POST }, response: { 200, 400 } })` and pair it with `Mandu.handler(contract, { GET, POST })`. Use `ctx.body<T>()`, `ctx.query`, `ctx.ok()`, `ctx.created()`, `ctx.unauthorized()` helpers — never read the raw Request directly.
- Handler files live at `app/api/<route>/route.ts` and export a `default` (file-system routing)
- Filling chain shape — `Mandu.filling().use(...).guard(...).get(...).post(...)`; never instantiate `new Mandu()` or write a raw `Request` handler
- Contract shape — `Mandu.contract({ request: { GET, POST, ... }, response: { 200, 400, ... } })` with zod schemas keyed by HTTP method / status code
- Use `ctx.body<T>()`, `ctx.query`, and the response helpers (`ctx.ok`, `ctx.created`, `ctx.unauthorized`, `ctx.redirect`) — Mandu Guard rejects raw `Response` construction in API handlers
- Middleware runs before handlers — chain with `.use(withSession())`, not inside the body