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.yaml configuration, 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.ts as a thin HTTP adapter. Business logic lives in framework-agnostic services that import nothing from next/*.

If you internalise nothing else from this document, internalise that sentence. Everything below is a concrete consequence of it.


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 → format

The 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-metadata is 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 my src/modules/ still compile and run under plain node with 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, NextResponse streaming helpers).
  • revalidatePath / revalidateTag / unstable_cache.
  • export const runtime = 'edge' | 'nodejs' and other route-segment config.
  • route.ts file 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".