LangENKO

Build an API endpoint

Two patterns for an API route — a quick filling chain, or a type-safe contract. One prompt covers both.

since v0.22
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 (return ctx.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.id is string, ctx.body.name is string
  • 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

🤖 Agent Prompt — Add an API endpoint
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.ts is recognised by its default export. Named exports are ignored.
  • Don't construct Response directly. Always go through ctx.ok(), ctx.created(), ctx.unauthorized(), ctx.redirect(). Mandu Guard rejects raw new Response(...) in app/api/**.
  • Middleware on the chain, not in the body. Use .use(withSession()) and .use(withCsrf()), not if (!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.

For Agents

AI hint

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.

Invariants
  • 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
Guard scope
api-route