Valcraven Docs
Building with Valcraven

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:

ClassStatusUse for
BadRequestError400invalid or missing input
UnauthorizedError401no valid session
ForbiddenError403authenticated but not allowed
NotFoundError404resource doesn't exist (or isn't the caller's)
ConflictError409version/uniqueness conflict
RateLimitError429throttled

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. The items route logs item.create / item.update / item.delete. See Admin panel.
  • Public API reference. To surface an endpoint on the API reference, add an @openapi JSDoc block above the handler — the generator only includes annotated routes. See API endpoints.

On this page