157 lines
5.5 KiB
Markdown
157 lines
5.5 KiB
Markdown
|
|
---
|
||
|
|
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`.
|