Next.js API Guidelines
These guidelines are normative for any project on the WBSP platform that chooses Next.js as its web + API stack. They exist so that an API written today in Next.js can be ported to a different server framework — most likely NestJS — with minimal rework if the project ever outgrows Next's API surface (richer dependency injection, modules, guards, interceptors, durable background processing, etc.).
Keeping the logic layer framework-agnostic costs almost nothing up front and pays back the whole rewrite cost if/when porting happens.
Read this alongside, not instead of, wbsp-client-guidelines.md and application-creator-guide.md. The rules in those documents (folder layout under
/target,wbsp.yamlconfiguration, environment-variable contract, structured logging, etc.) still apply unchanged. The rules below cover only how to structure the API code itself.
1. The core principle
Treat every Next.js
route.tsas a thin HTTP adapter. Business logic lives in framework-agnostic services that import nothing fromnext/*.
If you internalise nothing else from this document, internalise that sentence. Everything below is a concrete consequence of it.
2. Recommended folder layout
A feature-module layout that mirrors NestJS modules:
src/
modules/
<feature>/
<feature>.service.ts ← pure TS, no Next imports
<feature>.repository.ts ← Prisma / TypeORM only
<feature>.dto.ts ← Zod schemas or class-validator classes
<feature>.errors.ts ← typed domain errors
lib/
container.ts ← composition root (single instantiation)
errors/toResponse.ts ← maps domain errors → HTTP responses
app/api/<feature>/route.ts ← Next adapter: parse → call service → formatThe point of grouping service + repository + dto + errors in one folder is
that a NestJS port adds exactly one file to each folder — a
<feature>.module.ts declaring the providers — and the rest of the folder is
already in the right shape.
3. The rules
3.1 No next/* imports in services
NextRequest, NextResponse, cookies(), headers(),
revalidatePath(), redirect() — none of these may appear in a service,
repository, DTO, or domain-error file. They belong only in route.ts.
The adapter's job is to translate the HTTP world (a NextRequest) into
the service's world (validated DTOs, primitive parameters) and back. The
service must be callable with nothing but plain objects.
3.2 Services are constructor-injected classes
Write services as classes that take their dependencies through the constructor:
// src/modules/users/users.service.ts
export class UsersService {
constructor(private readonly repo: UsersRepository) {}
async getById(id: string) { … }
}Not as a module-level singleton (export const usersService = …) and not as
a bag of free functions. Instantiate exactly once in
src/lib/container.ts (the composition root) and import the instance from
there in route.ts. This maps 1:1 to NestJS's @Injectable() + constructor
DI — the port replaces container.ts with a NestJS Module and changes
nothing else.
3.3 Validation: Zod or class-validator
Both port. Pick one and use it everywhere:
class-validator+class-transformer+reflect-metadatais NestJS's native stack. Choose this if you want zero validation rewrite on port.- Zod is fine, often more ergonomic, and bridges to NestJS via
nestjs-zod.
Validate at the edge (in route.ts, before calling the service). The
service receives an already-typed, already-validated DTO and may assume
inputs are well-formed.
3.4 Typed domain errors, mapped at the edge
Throw typed errors from services:
// src/modules/users/users.errors.ts
export class NotFoundError extends Error {
constructor(public readonly resource: string) { super(`${resource} not found`) }
}Map them to HTTP in one place — src/lib/errors/toResponse.ts:
export function toResponse(err: unknown): Response {
if (err instanceof NotFoundError) return Response.json({ error: err.message }, { status: 404 })
// …other domain errors…
return Response.json({ error: 'internal' }, { status: 500 })
}In a NestJS port, toResponse becomes an ExceptionFilter; the throw sites
do not move.
Never throw new NextResponse(...) or return NextResponse.json(...) from
a service.
3.5 Module-per-feature folder
Group controller + service + repository + DTO + errors for one feature in
one folder under src/modules/<feature>/. Resist the urge to split by layer
(src/services/, src/repositories/, src/dtos/ …) — that layout makes
NestJS modularisation harder, not easier.
3.6 Portable persistence
Prisma and TypeORM both run natively in NestJS. Use one. Inject the
client/DataSource through the service constructor; do not reach into
globalThis, module-level imports, or import { prisma } from '@/db'
inside services.
// good
new UsersService(new UsersRepository(prisma))
// bad — bakes a global dependency into the service
class UsersService {
async getById(id: string) { return prisma.user.findUnique({ where: { id } }) }
}3.7 Config as an injected object
Read every environment variable once at boot into a ConfigService
class, and inject that into anything that needs config:
export class ConfigService {
readonly databaseUrl = required('DATABASE_URL')
readonly redisHost = required('REDIS_HOST')
// …
}Services and repositories take ConfigService as a constructor argument —
they never call process.env directly. NestJS has @nestjs/config with the
same shape; the port reuses your ConfigService essentially as-is.
3.8 Auth lives in the adapter, not the service
Extract the calling user's identity (from cookies, JWT, headers — whatever
the project uses) in route.ts, and pass it as a plain parameter to the
service:
// app/api/orders/route.ts
export async function POST(req: NextRequest) {
const userId = await currentUserId(req) // adapter concern
const dto = CreateOrderDto.parse(await req.json())
try {
const order = await container.orders.create(userId, dto)
return Response.json(order, { status: 201 })
} catch (e) { return toResponse(e) }
}Services receive userId (and any role information they need) as
arguments. They do not call cookies() or read Authorization headers.
In NestJS this separation becomes a Guard plus a @CurrentUser()
decorator — the service signature does not change.
4. The portability acid test
After every meaningful change, ask:
If I deleted
app/tomorrow, would mysrc/modules/still compile and run under plainnodewith no Next.js installed?
If yes, you are portable. If no, framework leakage has crept into the logic layer — find it and lift it back into the adapter.
A quick mechanical check that catches most leaks:
grep -R "from 'next" src/modules src/lib/errors src/lib/container.ts || echo "clean"Anything other than clean is a violation of §3.1.
5. What stays Next-specific (and that is fine)
These belong in route.ts and should not be lifted out for portability's
sake — porting a thin adapter is cheap, twisting your service layer to
avoid them is not:
- Streaming responses (
ReadableStream,NextResponsestreaming helpers). revalidatePath/revalidateTag/unstable_cache.export const runtime = 'edge' | 'nodejs'and other route-segment config.route.tsfile naming and the App Router's request handling conventions.
Rewriting an adapter file under NestJS is a few minutes per route. Twisting a service to be ignorant of HTTP streaming details that it would have encoded cleanly in Next is days of work for no gain — keep the streaming in the adapter, return an async iterator (or whatever fits) from the service.
6. A worked example
A complete users feature, end to end. Roughly the smallest thing that
demonstrates every rule above.
// src/modules/users/users.dto.ts
import { z } from 'zod'
export const CreateUserDto = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export type CreateUserDto = z.infer<typeof CreateUserDto>// src/modules/users/users.errors.ts
export class NotFoundError extends Error {
constructor(public readonly resource: string) { super(`${resource} not found`) }
}
export class EmailTakenError extends Error {
constructor(public readonly email: string) { super(`email in use: ${email}`) }
}// src/modules/users/users.repository.ts
import type { PrismaClient } from '@prisma/client'
export class UsersRepository {
constructor(private readonly db: PrismaClient) {}
findById(id: string) { return this.db.user.findUnique({ where: { id } }) }
findByEmail(email: string) { return this.db.user.findUnique({ where: { email } }) }
create(data: { email: string; name: string }) { return this.db.user.create({ data }) }
}// src/modules/users/users.service.ts
import { UsersRepository } from './users.repository'
import { CreateUserDto } from './users.dto'
import { NotFoundError, EmailTakenError } from './users.errors'
export class UsersService {
constructor(private readonly repo: UsersRepository) {}
async getById(id: string) {
const user = await this.repo.findById(id)
if (!user) throw new NotFoundError('user')
return user
}
async create(dto: CreateUserDto) {
if (await this.repo.findByEmail(dto.email)) throw new EmailTakenError(dto.email)
return this.repo.create(dto)
}
}// src/lib/container.ts
import { PrismaClient } from '@prisma/client'
import { UsersRepository } from '@/modules/users/users.repository'
import { UsersService } from '@/modules/users/users.service'
const prisma = new PrismaClient()
export const container = {
users: new UsersService(new UsersRepository(prisma)),
}// src/lib/errors/toResponse.ts
import { NotFoundError, EmailTakenError } from '@/modules/users/users.errors'
export function toResponse(err: unknown): Response {
if (err instanceof NotFoundError) return Response.json({ error: err.message }, { status: 404 })
if (err instanceof EmailTakenError) return Response.json({ error: err.message }, { status: 409 })
return Response.json({ error: 'internal' }, { status: 500 })
}// app/api/users/route.ts
import type { NextRequest } from 'next/server'
import { container } from '@/lib/container'
import { toResponse } from '@/lib/errors/toResponse'
import { CreateUserDto } from '@/modules/users/users.dto'
export async function POST(req: NextRequest) {
try {
const dto = CreateUserDto.parse(await req.json())
const user = await container.users.create(dto)
return Response.json(user, { status: 201 })
} catch (e) { return toResponse(e) }
}Note the shape: route.ts is six lines of real work — parse, call, format
— and everything else is plain TypeScript that knows nothing about Next.js.
A NestJS port replaces container.ts with a UsersModule declaring the
same two providers, replaces route.ts with a UsersController, and
turns toResponse into an ExceptionFilter. Every file in
src/modules/users/ is untouched.
7. Deviation
If these patterns cannot be applied or are not appropriate for some part of your project, do not silently diverge: tell the user the specific problem, ask for permission to deviate, and document the decision and reason clearly (in your project's constitution or an ADR). The escape hatch is "explain and substitute", never "skip quietly".