Add an API route
The standard API route handler — force-dynamic + nodejs, resolve the Better Auth session, scope queries to the user, and return errors through errorResponse().
Add an API route
Every server endpoint in Valcraven is a Next.js App Router route handler under app/api/. They all follow one template so auth, error shape, and runtime behavior stay consistent. This page is that template; the live reference is the example CRUD feature at app/api/items/route.ts and app/api/items/[id]/route.ts.
The template
Copy this shape for any route that touches the database or a service:
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getDb } from "@/lib/db";
import { UnauthorizedError, errorResponse } from "@/lib/errors";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new UnauthorizedError();
const db = getDb();
const data = await db
.select()
.from(/* your table */)
.where(/* eq(table.user_id, session.user.id) */);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}Four things make this the template, not just an example:
1. export const dynamic = "force-dynamic" and export const runtime = "nodejs"
Both are required on every API route. force-dynamic opts the route out of static optimization (it reads request headers and the database), and runtime = "nodejs" selects the Node runtime the auth and DB layers expect. Forgetting dynamic is one of the most common mistakes — it can cause a route to be evaluated at build time with no request context.
2. Resolve the session with auth.api.getSession
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new UnauthorizedError();auth comes from @/lib/auth and is server-only — never import it in a client component. The session gives you session.user.id, session.user.email, and the custom fields like session.user.plan. If you want the route to also accept a programmatic API key, use authenticateRequest() instead — see Protect a route with API keys.
3. Scope every query to the session user
Filter by session.user.id, never a constant. Every user-owned table has a user_id foreign key to user(id), so the whole schema is multi-tenant by default — a missing WHERE user_id = … leaks other users' data.
4. Return errors through errorResponse()
Wrap the body in try/catch and hand the caught error to errorResponse(error) from @/lib/errors. It maps the typed error classes to the right status and a consistent JSON body { error, code }, and turns any unknown error into a generic 500 without leaking internals. Throw the semantic errors rather than building responses by hand:
| Class | Status | Use for |
|---|---|---|
BadRequestError | 400 | invalid or missing input |
UnauthorizedError | 401 | no valid session |
ForbiddenError | 403 | authenticated but not allowed |
NotFoundError | 404 | resource doesn't exist (or isn't the caller's) |
ConflictError | 409 | version/uniqueness conflict |
RateLimitError | 429 | throttled |
All are exported from @/lib/errors.
Writes: validate the input
For POST/PATCH/PUT, validate the body before writing. The template shares Zod schemas across web and mobile in @valcraven/core, so the same validation runs on both. The items route uses createItemInputSchema:
import { createItemInputSchema } from "@valcraven/core";
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new UnauthorizedError();
const parsed = createItemInputSchema.safeParse(await request.json());
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid input";
return NextResponse.json({ error: message }, { status: 400 });
}
const { name, description } = parsed.data;
// … insert scoped to session.user.id, then return 201
} catch (error) {
return errorResponse(error);
}
}Dynamic segments
Route params are async in the App Router. A route at app/api/items/[id]/route.ts receives them as a promise:
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// scope the delete to BOTH the id AND the session user id, so a user can
// only delete their own rows — see app/api/items/[id]/route.ts
}For deletes and updates, use executeChanges() from @/lib/db-helpers to get a portable affected-row count — it normalizes the count across better-sqlite3, D1, and Postgres (a subtle source of dev/prod bugs; see D1 dev/prod parity). A count of 0 means "not found or not yours" → return 404.
Optional: audit logging and public API docs
- Audit log. Sensitive mutations call
logAuditFromRequest(request, { action, actorId, … })from@/lib/audit. Theitemsroute logsitem.create/item.update/item.delete. See Admin panel. - Public API reference. To surface an endpoint on the API reference, add an
@openapiJSDoc block above the handler — the generator only includes annotated routes. See API endpoints.
Related
- Add a feature — where this route fits in an end-to-end feature.
- Protect a route with API keys — accept
sk_…keys, not just sessions. - Data layer —
getDb(), Drizzle, and the SQLite/D1/Postgres dialects. - Authentication — the session object and its custom user fields.
Add a feature
The golden path for adding an authenticated feature — model the data, add a scoped API route, and render the UI inside the /app shell without breaking the conventions the template relies on.
Add an admin page
How the /admin section is structured — the RBAC layout gate, the sidebar nav, and requireAdmin() for API routes — and how to add a new admin page.