Skip to main content

CRUD API Example

A complete products API with list, read, create, update, and delete, using file-based routing, Zod validation, and ctx.set.

Each route directory is self-contained: route.ts holds the handlers, schema.ts holds the per-method validation schemas.

Store​

src/api/products/store.ts
export interface Product {
id: string;
name: string;
price: number;
}

export const products: Product[] = [];

List with query + response headers​

src/api/products/schema.ts
import { z } from "zod";

export const GET = {
query: z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
page: z.coerce.number().int().min(1).default(1),
}),
};

export const POST = {
body: z.object({
name: z.string().min(1),
price: z.number().positive(),
}),
};
src/api/products/route.ts
import { defineRoute } from "burger-api";
import { GET as GETSchema, POST as POSTSchema } from "./schema";
import { products } from "./store";

export const GET = defineRoute(GETSchema, (ctx) => {
const { limit, page } = ctx.validated.query;
const start = (page - 1) * limit;
const items = products.slice(start, start + limit);

ctx.set = { headers: { "x-total": String(products.length) } };
return Response.json({ page, limit, items });
});

export const POST = defineRoute(POSTSchema, (ctx) => {
const product = { id: crypto.randomUUID(), ...ctx.validated.body };
products.push(product);
ctx.set = { status: 201 };
return Response.json(product);
});

Read, update, and delete one​

src/api/products/[id]/schema.ts
import { z } from "zod";

export const GET = {
params: z.object({ id: z.string() }),
};

export const PUT = {
params: z.object({ id: z.string() }),
body: z.object({
name: z.string().min(1).optional(),
price: z.number().positive().optional(),
}),
};

export const DELETE = {
params: z.object({ id: z.string() }),
};
src/api/products/[id]/route.ts
import { defineRoute } from "burger-api";
import { GET as GetSchema, PUT as PutSchema, DELETE as DeleteSchema } from "./schema";
import { products } from "../store";

export const GET = defineRoute(GetSchema, (ctx) => {
const product = products.find((p) => p.id === ctx.validated.params.id);
if (!product) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(product);
});

export const PUT = defineRoute(PutSchema, (ctx) => {
const product = products.find((p) => p.id === ctx.validated.params.id);
if (!product) return Response.json({ error: "Not found" }, { status: 404 });
Object.assign(product, ctx.validated.body);
return Response.json(product);
});

export const DELETE = defineRoute(DeleteSchema, (ctx) => {
const index = products.findIndex((p) => p.id === ctx.validated.params.id);
if (index === -1) return Response.json({ error: "Not found" }, { status: 404 });
products.splice(index, 1);
return new Response(null, { status: 204 });
});

ctx.set collects status and header changes, applied once when the response leaves the app. See Response Mutation.

This example shows routing, validation, and response mutation working together. See also the Todo API and Blog API.