API Layer
Route handlers, server actions, OpenAPI spec assembly, and the Swagger UI console.
app/api/ contains only Next.js route handlers. Handlers simply validate input, call a service, and shape the response.
Reads vs writes
| Operation | Lives in | Why |
|---|---|---|
| Reads | GET route handlers in app/api/ | Cacheable, addressable, reachable from SWR, curl, and Swagger |
| Writes from our UI | Server Actions in actions/ | Type-safe args, no hand-written fetch glue, progressive enhancement |
| Writes from outside | Route handlers in app/api/ | Webhooks, cron jobs, and third-party callers need a real HTTP surface |
Why server actions for UI writes?
Server Actions are an internal RPC bound to our own frontend. A form or
button calls a typed function directly, no fetch, no endpoint URL, no JSON serialisation by hand.
Compared to a route handler, actions give you:
- No fetch glue - Call the function directly from a component or form. No API client to maintain.
- Type-safe arguments - TypeScript enforces the call signature end-to-end. A route handler's
request.json()isunknownuntil you parse it. revalidatePath/revalidateTag- Actions can purge the Next.js cache inline, so the UI updates immediately after a mutation without a manual refetch.- Progressive enhancement - Forms with an
action=attribute work without JavaScript. Route handlers don't give you this. - Simpler error handling - Return a discriminated result (
{ ok: true, data }/{ ok: false, error }); the component branches on it. No HTTP status codes to map back into UI state.
The moment an external caller needs to write (a Slack webhook, a cron job, a mobile client), use a route handler instead.
Structure
The API routes are organised in the app/api directory. Each subdirectory
represents a logical grouping of related endpoints:
Dynamic segments are bracketed folders ([id]). Each route.ts groups all
HTTP methods for that URL as named exports.
The example-items/ vertical is the template's full-CRUD REST reference (GET
/ POST / PATCH / DELETE). It exists to demonstrate the pattern and give
Swagger something to document
Route handler structure
Every handler follows the same shape:
Validate at the boundary
Call the service
Shape the response
// app/api/example-items/route.ts
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
// 1. Validate at the boundary - parse untrusted input before it reaches the service
const parsed = exampleItemSchema.safeParse(await request.json());
if (!parsed.success) {
return validationErrorResponse(parsed.error); // 400
}
// 2. Call the service - business logic lives here, not in the handler
const exampleItem = await exampleItemService.create(parsed.data);
logger.info({ message: 'Example item created', id: exampleItem.id });
// 3. Shape the response
return NextResponse.json({ exampleItem }, { status: 201 });
} catch (error) {
return routeErrorHandler(error, 'POST /api/example-items'); // 500
}
}The rules:
- Named exports for each HTTP method (
GET,POST,PATCH,DELETE) - Every handler is typed:
(request: NextRequest): Promise<NextResponse>. - Zod
safeParseat the boundary before anything reaches the service. validationErrorResponsefor 400,routeErrorHandlerfor 500 - both fromapp/api/utils/. Consistent error shapes across every route.- Log mutations at the handler. Creates, updates, and deletes are worth a log line because they give you a quick audit trail ("item X was created", "item Y was deleted"). Don't log every read, that's just noise.
- The service layer rethrows rather than logging, so a failure is never recorded twice under two sources.
Dynamic route params in Next.js 15+
In Next.js 15 +, dynamic route params are async and must be awaited before
their values can be read. The route context is typed accordingly:
// app/api/example-items/[id]/route.ts
type RouteContext = { params: Promise<{ id: string }> };
export async function GET(
_request: NextRequest,
context: RouteContext,
): Promise<NextResponse> {
const id = parseId((await context.params).id);
// ...
}parseId from app/api/utils/ validates the raw string into a positive
integer. Route params always arrive as strings, so guard before they reach the
service.
Shared handler utilities
app/api/utils/index.ts holds the small functions used by every handler:
| Function | What it does |
|---|---|
validationErrorResponse(error) | Returns a 400 with flattened Zod field errors |
routeErrorHandler(error, context) | Logs the failure and returns a 500 with a consistent envelope |
parseId(raw) | Parses a route param string into a positive integer, or null |
withAuth(handler) | Wraps a handler to require authentication (401 otherwise) |
withRole(role, handler) | Wraps a handler to require a minimum role (401/403 otherwise) |
hasRole(callerRole, requiredRole) | Role-hierarchy check (SuperAdmin >= Admin) |
Auth: withAuth and withRole
Every route handler enforces auth in the handler itself. Two small wrappers in
app/api/utils/ do this:
| Wrapper | Enforces | On failure |
|---|---|---|
withAuth(handler) | Caller is signed in | 401 Unauthorized |
withRole(role, handler) | Caller is signed in and has role | 401 or 403 |
A wrapped handler receives an AuthContext ({ userId, role }) as its third
argument, so it never re-reads the session:
// Read - any authenticated caller
export const GET = withAuth(async (_request, _context, { userId }) => {
// userId is guaranteed here
return NextResponse.json({ items: await service.getAll() });
});
// Write - SuperAdmin only
export const POST = withRole('SuperAdmin', async (request) => {
// caller is an authenticated SuperAdmin
});The two-role model
The template ships two roles with a hierarchy, not two unrelated flags:
Admin- gets into the app and can read (withAuth).SuperAdmin- a strict superset: everything Admin can do, plus writes (withRole('SuperAdmin')). A SuperAdmin therefore also satisfieswithRole('Admin').
hasRole(callerRole, requiredRole) encodes this - it compares positions in a
role hierarchy rather than doing string equality, so "SuperAdmin can do
everything" holds by construction. In the example CRUD vertical:
| Method | Wrapper | Who can call |
|---|---|---|
GET /api/example-items | withAuth | Any Admin+ |
GET /api/example-items/[id] | withAuth | Any Admin+ |
POST /api/example-items | withRole('SuperAdmin') | SuperAdmin only |
PATCH /api/example-items/[id] | withRole('SuperAdmin') | SuperAdmin only |
DELETE /api/example-items/[id] | withRole('SuperAdmin') | SuperAdmin only |
GET /api/posts | withAuth | Any Admin+ |
Gating every write behind SuperAdmin is a teaching choice - it gives
you two test users to prove both the allowed and denied paths. In a real
product you pick the role per route (often per resource, e.g. an ownership
check), not blanket-SuperAdmin on every mutation.
Server actions mirror the same gate
A 'use server' function is a remotely-invokable POST endpoint - it is a write
surface just like a route handler. The mutating actions in
actions/example-item-actions.ts therefore run the same authentication and
SuperAdmin check (via a shared authorizeMutation helper) before touching the
DB. Harden only the routes and leave the actions open and you have bolted the
front door while leaving the side door ajar. Both surfaces share the same Zod
schema and the same role gate so they cannot drift.
Because actions return a discriminated ActionResult, a denied write comes back
as { ok: false, error: 'You do not have the correct role...' }, which the form
already surfaces as a toast - no extra wiring needed.
force-dynamic - when and why
Next.js statically analyses route handlers at build time and may cache GET responses that have no dynamic inputs (no headers, cookies, or search params). If your handler reads from a DB or calls an external API, you need to opt out of that caching:
// Opt out of static caching - this route must run on every request.
export const dynamic = 'force-dynamic';
export async function GET(): Promise<NextResponse> {
const items = await exampleItemService.getAll();
return NextResponse.json({ items });
}When to add it: any GET route whose response can change between requests
(DB reads, external API calls, authenticated data). Routes that already read
cookies or headers are implicitly dynamic - Next.js detects this. force-dynamic
is only needed when Next.js can't infer it.
OpenAPI and Swagger UI
Like everything in this template, this is pick-and-choose. Keep the docs up to date as you go, or ignore them entirely - the routes work either way.
Swagger isn't a common pattern in Next.js projects and there's no straightforward
path to auto-generating an OpenAPI spec from the filesystem. The approach here is
a pragmatic workaround: the spec is hand-assembled in src/lib/openapi/index.ts
from the same Zod schemas the routes already validate with. It's a small amount
of config per route, but it's manual.
The upside is that the request bodies, response shapes, and validators all come from one place - the docs cannot drift from what the API actually accepts.
How it works
The spec lives in src/lib/openapi/index.ts and is assembled from three things:
- Zod schemas annotated with
.meta()- these become the request/response shapes in the docs pathsentries - one per route operation, wiring schemas to HTTP methods and status codescreateDocumentfromzod-openapi- combines everything into an OpenAPI 3.1 document
The /api/openapi route serves the document as JSON. The /api-docs route serves a Swagger UI shell that points at it.
Adding a new route to the docs
Say you add POST /api/widgets. The route itself works without any of the steps below - they only affect the docs.