--- name: add-api-route description: Add a new Next.js App Router API route in src/app/api/**/route.ts following the repo's auth + multi-tenancy + error-handling conventions. Use when the user asks to add an endpoint, route, handler, GET/POST/PUT/DELETE, /api/X, or a server action backed by Prisma. --- # Add a new API route Every API route in this repo is a `src/app/api//route.ts` file that exports `GET` / `POST` / `PUT` / `DELETE`. They all follow the same shape: auth check → org-scoped Prisma access → JSON response, wrapped in `try / handleApiError`. ## Recipe ### 1. Pick the path App Router maps directories to URL segments. Dynamic segments use `[param]`: | Want | File | | --- | --- | | `GET /api/widgets` | `src/app/api/widgets/route.ts` | | `GET /api/widgets/[id]` | `src/app/api/widgets/[id]/route.ts` | | `POST /api/widgets/[id]/archive` | `src/app/api/widgets/[id]/archive/route.ts` | ### 2. Start from this template ```ts import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth"; export async function GET(request: NextRequest) { try { const session = await requireApiAuthWithOrg(); const { searchParams } = new URL(request.url); const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10))); const [items, total] = await Promise.all([ prisma.widget.findMany({ where: { organizationId: session.user.orgId }, orderBy: { createdAt: "desc" }, skip: (page - 1) * limit, take: limit, }), prisma.widget.count({ where: { organizationId: session.user.orgId } }), ]); return NextResponse.json({ items, total, page, limit }); } catch (error) { return handleApiError(error); } } export async function POST(request: NextRequest) { try { const session = await requireApiAuthWithOrg("widgets.create"); const body = await request.json().catch(() => ({})); if (!body.name) { return NextResponse.json({ error: "name is required" }, { status: 400 }); } const widget = await prisma.widget.create({ data: { organizationId: session.user.orgId, name: String(body.name), }, }); return NextResponse.json(widget, { status: 201 }); } catch (error) { return handleApiError(error); } } ``` ### 3. Dynamic route params are async in Next.js 16 ```ts export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const session = await requireApiAuthWithOrg(); const { id } = await params; const widget = await prisma.widget.findUnique({ where: { id } }); if (!widget || widget.organizationId !== session.user.orgId) { return NextResponse.json({ error: "Widget not found" }, { status: 404 }); } return NextResponse.json(widget); } catch (error) { return handleApiError(error); } } ``` Cross-org returns 404, not 403 (don't reveal existence). ### 4. Permission gating Two ways to enforce a permission: ```ts // Inline in the auth call — throws PermissionError → handleApiError returns 403 const session = await requireApiAuthWithOrg("cards.delete"); ``` ```ts // Or check explicitly when the policy is fancier import { can } from "@/lib/permissions"; const session = await requireApiAuthWithOrg(); if (!can(session.user.role, "cards.edit") && card.assignedToId !== session.user.id) { return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 }); } ``` `Action` is a union type in `src/lib/permissions.ts` — TypeScript will autocomplete the valid actions. Add a new action there if you need one. ### 5. Body handling Hand-rolled is the prevailing style. Use the defensive pattern: ```ts const body = await request.json().catch(() => ({})); const data: Record = {}; for (const field of ["name", "description"]) { if (body[field] != null) data[field] = String(body[field]); } if (body.published != null) data.published = Boolean(body.published); ``` For complex shapes, Zod is in `package.json` — using it in a new route is fine, just stay consistent within the file. ### 6. Updating denormalized `ResponseCard.name` If your route mutates `ResponseCard.firstName` or `lastName`, you MUST recompute `name`. The canonical block lives in `src/app/api/cards/[id]/route.ts` — copy it: ```ts if (("firstName" in data || "lastName" in data) && !("name" in data)) { const nextFirst = "firstName" in data ? (data.firstName as string | null) : card.firstName; const nextLast = "lastName" in data ? (data.lastName as string | null) : card.lastName; const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim(); data.name = combined || null; } ``` ### 7. After writing the route - Verify locally with `curl` or the Network tab. - If it's a user-visible endpoint, add a row to the API table in `README.md`. - Run `npm run lint` and `npx tsc --noEmit` before opening the PR. ## Anti-patterns - Skipping `organizationId` in `where:` filters → cross-org data leak. - `throw` instead of `return handleApiError(error)` → uncaught error in the framework. - Returning `403` for cross-org IDs instead of `404` → leaks existence. - Calling `prisma` from top-level module code → the lazy proxy needs `DATABASE_URL` at access time. - Adding `"use client"` to a route file → server-only. - Hand-writing `NextResponse.json({ error }, { status: 500 })` in a catch block → use `handleApiError`.