---
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/<segments>/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<string, unknown> = {};
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`.
