EEPNext.js Template
The Layers

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

OperationLives inWhy
ReadsGET route handlers in app/api/Cacheable, addressable, reachable from SWR, curl, and Swagger
Writes from our UIServer Actions in actions/Type-safe args, no hand-written fetch glue, progressive enhancement
Writes from outsideRoute 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() is unknown until 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:

route.ts// GET, PATCH, DELETE a specific example item
route.ts// GET all example items, POST new example item
route.ts// serves the OpenAPI spec JSON at /api/openapi
index.ts// shared handler utilities
route.ts// Swagger UI at /api-docs

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 safeParse at the boundary before anything reaches the service.
  • validationErrorResponse for 400, routeErrorHandler for 500 - both from app/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:

FunctionWhat 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)
Keep any shared handler logic in here

Auth: withAuth and withRole

Every route handler enforces auth in the handler itself. Two small wrappers in app/api/utils/ do this:

WrapperEnforcesOn failure
withAuth(handler)Caller is signed in401 Unauthorized
withRole(role, handler)Caller is signed in and has role401 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 satisfies withRole('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:

MethodWrapperWho can call
GET /api/example-itemswithAuthAny Admin+
GET /api/example-items/[id]withAuthAny Admin+
POST /api/example-itemswithRole('SuperAdmin')SuperAdmin only
PATCH /api/example-items/[id]withRole('SuperAdmin')SuperAdmin only
DELETE /api/example-items/[id]withRole('SuperAdmin')SuperAdmin only
GET /api/postswithAuthAny 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
  • paths entries - one per route operation, wiring schemas to HTTP methods and status codes
  • createDocument from zod-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.

On this page