๐Ÿช Comprehensive Next.js Full Stack App Architecture Guide

April 17, 2026 (Today)

Arno shares his best practices for designing robust Next.js full-stack applications, drawing from large-scale projects, and personal indie work. This guide is organized principles first: every section names a universal backend architecture concern, then shows the Next.js instantiation, with optional cross-language mirrors when useful.

Preface โ€” How to Read This Guide

A good architecture guide should outlive a single framework. So this article is structured in four parts:

  • Part I โ€” Universal Architectural Concerns: twelve concerns that show up in every serious backend, regardless of language. Each section gives the principle, why it matters, and the Next.js mapping. When the FastAPI/Python equivalent is illuminating, it gets a one-line callout.
  • Part II โ€” Next.js-Specific Surface: framework features that don't have a clean cross-language analog โ€” Server Components, Server Actions, the cache hierarchy, the edge runtime, the React client model.
  • Part III โ€” Two Philosophies: composition vs. convention โ€” when each one pays off in a Next.js codebase.
  • Part IV โ€” Patterns Worth Extracting: a transferable checklist you can lift into any new Next.js service.

The principles map cleanly onto FastAPI, Spring Boot, Rails, NestJS, or any other modern backend framework โ€” Next.js is just one concrete instantiation we walk through in detail.


Part I โ€” Universal Architectural Concerns

1. Layered Architecture & the Dependency Rule

Principle. Carve the system into a small number of layers and enforce a strict downward dependency rule. Higher layers may import from lower layers; lower layers must never import from higher layers. The bottom layer (shared) imports from nothing.

Why it matters. Without this rule, every layer eventually imports every other layer, and the codebase collapses into a ball of mud. With it, you can replace any layer in isolation โ€” swap the transport, swap the ORM, swap the framework.

Next.js mapping. The classical four-layer model maps onto the framework's seams:

+-----------------------------------------------------------------------------------------------+
|                                       CLIENT                                                  |
+-----------------------------------------------------------------------------------------------+
                |                          |                           |
                | Page Request             | API Request               | Server Action
                โ†“                          โ†“                           โ†“
+-----------------------------------------------------------------------------------------------+
|                                    CONTROLLER LAYER                                           |
|  +-----------------+    +------------------+    +---------------------+                       |
|  |   Page Route    |    |    API Route     |    |    Server Action    |                       |
|  +-----------------+    +------------------+    +---------------------+                       |
+-----------------------------------------------------------------------------------------------+
                                   | Call
                                   โ†“
+-----------------------------------------------------------------------------------------------+
|                                    SERVICE LAYER                                              |
|  +------------------+    +------------------+    +------------------+                         |
|  | Domain Service A |    | Domain Service B |    | Domain Service C |                         |
|  +------------------+    +------------------+    +------------------+                         |
+-----------------------------------------------------------------------------------------------+
                                   | Use
                                   โ†“
+-----------------------------------------------------------------------------------------------+
|                                   MANAGER LAYER                                               |
|  +------------------+    +------------------+    +------------------+                         |
|  |    Manager A     |    |    Manager B     |    |    Manager C     |                         |
|  +------------------+    +------------------+    +------------------+                         |
+-----------------------------------------------------------------------------------------------+
                                   | Access
                                   โ†“
+-----------------------------------------------------------------------------------------------+
|                             DATA PERSISTENCE LAYER (foundation)                               |
+-----------------------------------------------------------------------------------------------+
                                   โ†‘ uses
+-----------------------------------------------------------------------------------------------+
|                            SHARED (types, contracts, errors) โ€” imports nothing                |
+-----------------------------------------------------------------------------------------------+

The arrows point one way. shared/ (types, contracts, error classes) sits at the bottom and imports nothing. foundation/ (db, cache, queue, logger) imports only shared. Services import foundation + shared. Controllers import services + shared. Never the reverse.

Enforce it mechanically โ€” humans drift:

  • eslint-plugin-boundaries or dependency-cruiser to declare and check layer rules in CI.
  • tsconfig paths to make layer imports explicit (@app/services/*, @app/foundation/*, @shared/*).
  • A pre-commit dependency-cruiser run is cheap insurance.

Cross-language mirror. FastAPI/Python services expressing the same rule typically use gateway โ†’ app/core โ†’ framework โ†’ foundation โ†’ shared. The names differ; the dependency rule is identical.

2. Configuration as a Tiered Strategy

Principle. Production systems need three tiers of config: static (compiled into the build), composable (assembled per-feature/per-tenant at startup), and dynamic (hot-reloadable at runtime without redeploy).

Why it matters. Stuffing everything into .env works until you need feature flags, per-tenant LLM model overrides, or A/B test gates. Stuffing everything into a remote config service makes local dev painful. Three tiers, three responsibilities.

Next.js mapping.

  • Static (build-time). A single configs/env.ts validated by zod at module load. Fail loudly if a required env is missing โ€” never silently fall back.
// configs/env.ts
import { z } from 'zod'
 
export const env = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  OPENAI_API_KEY: z.string().min(1),
  NODE_ENV: z.enum(['development', 'staging', 'production'])
}).parse(process.env)
  • Composable (startup-time). Per-feature or per-tenant config objects assembled from typed building blocks. Useful for AI agents (different tools per persona), multi-brand sites, white-label deployments.
// services/agent/agent.config.ts
export function buildAgentConfig(persona: PersonaId): AgentConfig {
  return {
    ...baseAgentConfig,
    tools: toolsByPersona[persona],
    model: modelByPersona[persona] ?? defaultModel
  }
}
  • Dynamic (runtime). Vercel EdgeConfig, LaunchDarkly, Statsig, or a Redis-backed flag service. Subscribe to changes; do not poll on every request.

Cross-language mirror. FastAPI services often use Hydra YAML composition for tier 2 and a Nacos/Consul DynamicConfigManager for tier 3. The pattern is the same; the libraries differ.

3. Application Lifecycle & Lifespan Management

Principle. Every long-lived resource (DB pool, queue client, websocket manager, ML model) needs an explicit start and stop hook. Leaks at shutdown are a silent reliability problem.

Why it matters. Serverless hides this until it bites you on a long-running container, an Edge function with persistent state, or a self-hosted deployment. The discipline pays back the moment you scale beyond a single Vercel function.

Next.js mapping.

  • Startup. Next.js 14+ exposes instrumentation.ts โ€” register OpenTelemetry, warm a DB pool, prefetch dynamic config.
// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { initTracing } = await import('./lib/server/tracing')
    const { warmDbPool } = await import('./lib/server/db')
    const { startQueueConsumers } = await import('./lib/server/queue')
 
    await initTracing()
    await warmDbPool()
    await startQueueConsumers()
  }
}
  • Singletons in node runtime. Use the globalThis ref pattern so HMR and Next.js' multiple module copies don't create N database pools:
// lib/server/db.ts
import { PrismaClient } from '@prisma/client'
 
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
  • Shutdown. When you self-host, listen for SIGTERM and drain gracefully. On Vercel, use waitUntil and the new after() primitive to defer non-critical work past the response.
  • Health. Expose a cheap /api/health route that pings deps with a budget โ€” never block a health check on a slow dependency.

Cross-language mirror. FastAPI's @asynccontextmanager lifespan performs the same role: start clients, register loop-lag monitoring, sweep aiohttp sessions on shutdown.

4. The Universal IO Contract

Principle. Every entry point โ€” page, API route, server action โ€” speaks the same response shape. Errors and successes are values, not exceptions, by the time they leave the service layer.

Why it matters. A unified contract means client code can render any response with one branch (result.ok), error reporting is uniform, and the controller layer becomes a thin transport adapter with no per-route branching.

Next.js mapping. Codify a ServiceResult<T> discriminated union that flows from service โ†’ controller โ†’ client:

// shared/contracts/result.ts
export type ServiceResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: BizError }
 
export const ok = <T>(data: T): ServiceResult<T> => ({ ok: true, data })
export const fail = (error: BizError): ServiceResult<never> => ({ ok: false, error })

Used everywhere:

// service
async function getElaborator(id: string): Promise<ServiceResult<Elaborator>> {
  const e = await repo.findById(id)
  return e ? ok(e) : fail(new NotFoundError('elaborator', id))
}
 
// API route
export const GET = composeAPIRoute([authUser, async (req, ctx) => {
  return Response.json(await elaboratorService.getElaborator(ctx.params.id))
}])
 
// server action
export async function getElaboratorAction(id: string) {
  return elaboratorService.getElaborator(id)
}

The client renders any of them with one pattern:

const result = await getElaboratorAction(id)
if (!result.ok) return <ErrorView error={result.error} />
return <ElaboratorView data={result.data} />

Cross-language mirror. Python services with an AppRequest โ†’ AppResponse contract (or Rust's Result<T, E>) are doing the same thing โ€” values, not exceptions, at the boundary.

5. Entity Lifecycle Hooks, Optimistic Locking & State Machines

Principle. CRUD is rarely just CRUD. Every entity has lifecycle hooks (before/after create, update, delete), needs optimistic locking to handle concurrent writes, and often a state machine for valid transitions. Encode all three once, reuse everywhere.

Why it matters. Without this, every service file re-invents validation, audit logging, lock checks, and status guards. With it, a new resource is one file of hook overrides.

Next.js mapping. Sketch a BaseEntityHandler<T> that resources extend:

// lib/server/entity/base-handler.ts
export abstract class BaseEntityHandler<T extends { id: string; version: number }> {
  async beforeCreate(data: Partial<T>, ctx: UserContext): Promise<Partial<T>> { return data }
  async afterCreate(entity: T, ctx: UserContext): Promise<void> {}
  async beforeUpdate(curr: T, patch: Partial<T>, ctx: UserContext): Promise<Partial<T>> { return patch }
  async afterUpdate(prev: T, next: T, ctx: UserContext): Promise<void> {}
  async beforeDelete(entity: T, ctx: UserContext): Promise<void> {}
  async afterDelete(entity: T, ctx: UserContext): Promise<void> {}
  async onTransition(entity: T, action: string, target: string, ctx: UserContext): Promise<void> {}
}

Optimistic locking with Prisma โ€” a version column checked in the WHERE clause:

async update(entity: T, patch: Partial<T>) {
  const result = await prisma.$transaction(async (tx) => {
    const updated = await tx.entity.updateMany({
      where: { id: entity.id, version: entity.version },
      data: { ...patch, version: { increment: 1 } }
    })
    if (updated.count === 0) throw new ConflictError('optimistic-lock', entity.id)
    return tx.entity.findUniqueOrThrow({ where: { id: entity.id } })
  })
  return result
}

State machine as a typed transition table โ€” no string-typing, no boolean soup:

type Status = 'draft' | 'pending' | 'approved' | 'published' | 'archived'
type Action = 'submit' | 'approve' | 'reject' | 'publish' | 'archive' | 'rollback'
 
const transitions: Record<Status, Partial<Record<Action, Status>>> = {
  draft:     { submit: 'pending' },
  pending:   { approve: 'approved', reject: 'draft' },
  approved:  { publish: 'published' },
  published: { archive: 'archived', rollback: 'approved' },
  archived:  {}
}

Cross-language mirror. Python platforms typically have an EntityHandler with the same hook surface and a ResourceStateMachine class โ€” the discipline transfers wholesale.

6. Per-Resource Module Convention

Principle. When you have many similar resources (CRUD-heavy admin platforms, multi-entity SaaS), adopt a formulaic per-resource module structure. Adding a resource becomes a copy-paste-fill exercise, not an architectural debate.

Why it matters. Conventions remove decisions. A team scaling fast benefits more from "no choice" than from "the right choice for this case."

Next.js mapping.

services/
โ””โ”€โ”€ elaborator/
    โ”œโ”€โ”€ elaborator.service.ts      โ† orchestration; returns ServiceResult<T>
    โ”œโ”€โ”€ elaborator.handler.ts      โ† BaseEntityHandler subclass; lifecycle hooks
    โ”œโ”€โ”€ elaborator.repository.ts   โ† BaseRepository subclass; data access
    โ”œโ”€โ”€ elaborator.validator.ts    โ† zod schemas for input
    โ”œโ”€โ”€ elaborator.types.ts        โ† entity + DTO types
    โ””โ”€โ”€ elaborator.route.ts        โ† thin re-exports for /app/api/v1/elaborators/*

The service is a thin coordinator:

async create(input: CreateInput, ctx: UserContext): Promise<ServiceResult<Elaborator>> {
  const data = await this.validator.parseCreate(input)
  const prepared = await this.handler.beforeCreate(data, ctx)
  const entity = await this.repo.insert(prepared, ctx.tenantId)
  await this.handler.afterCreate(entity, ctx)
  return ok(entity)
}

The convention pays off most when you have 10+ resources. For 2-3 resources, it's overkill โ€” go composition (see ยง11 / Part III).

Cross-language mirror. FastAPI platform services with 30+ resources use exactly this 4-file pattern (*_service.py, *_handler.py, *_repository.py, *_validator.py).

7. Generic Typed Repository

Principle. Encapsulate persistence behind a generic typed repository. The data layer should be the only place that knows about your storage technology.

Why it matters. Swap Postgres for an Elasticsearch read model, add tenant routing, add caching โ€” all in one place, transparently to services.

Next.js mapping. Sketch a BaseRepository<T> over Prisma with tenant scoping baked in:

// lib/server/repository/base.ts
export abstract class BaseRepository<T extends { id: string }> {
  protected abstract model: any
  protected abstract serialize(entity: T): Record<string, unknown>
  protected abstract deserialize(row: Record<string, unknown>): T
 
  async findById(id: string, tenantId: string): Promise<T | null> {
    const row = await this.model.findFirst({ where: { id, tenantId } })
    return row ? this.deserialize(row) : null
  }
 
  async insert(entity: T, tenantId: string): Promise<T> {
    const row = await this.model.create({ data: { ...this.serialize(entity), tenantId } })
    return this.deserialize(row)
  }
 
  async search(query: QueryBuilder<T>, tenantId: string): Promise<T[]> {
    const rows = await this.model.findMany({ where: { ...query.toPrisma(), tenantId } })
    return rows.map((r: Record<string, unknown>) => this.deserialize(r))
  }
}

A small fluent QueryBuilder makes call sites readable without leaking Prisma's syntax into services. Tenant routing is enforced at the base โ€” services cannot accidentally cross tenants.

Cross-language mirror. Python's ESRepository[TEntity: BaseModel] is the same pattern with TypeVar bounds and account_routing instead of tenantId.

8. Typed Exception Hierarchy โ†’ Transport Mapping

Principle. Business code throws (or returns) typed business errors. The transport layer (HTTP/SSE) maps them to wire codes. Business code never touches HTTP status codes.

Why it matters. Services become testable without HTTP infrastructure, errors are exhaustively documentable, and changing the transport (REST โ†’ tRPC โ†’ SSE) doesn't ripple into business code.

Next.js mapping. A small hierarchy in shared/:

// shared/errors/biz-error.ts
export class BizError extends Error {
  constructor(public code: string, message: string, public meta?: unknown) {
    super(message)
  }
}
export class ValidationError    extends BizError {} // โ†’ 400
export class AuthError          extends BizError {} // โ†’ 401
export class PermissionError    extends BizError {} // โ†’ 403
export class NotFoundError      extends BizError {} // โ†’ 404
export class ConflictError      extends BizError {} // โ†’ 409
export class RateLimitError     extends BizError {} // โ†’ 429
export class ExternalSvcError   extends BizError {} // โ†’ 502

A single mapper composed into every route:

// lib/server/route/map-errors.ts
const STATUS: Record<string, number> = {
  ValidationError: 400, AuthError: 401, PermissionError: 403,
  NotFoundError: 404, ConflictError: 409, RateLimitError: 429,
  ExternalSvcError: 502
}
 
export const mapErrors: Middleware = async (req, ctx, next) => {
  try {
    return await next()
  } catch (e) {
    if (e instanceof BizError) {
      return Response.json(
        { ok: false, error: { code: e.code, message: e.message } },
        { status: STATUS[e.constructor.name] ?? 500 }
      )
    }
    logger.error('[unhandled]', e)
    return Response.json({ ok: false, error: { code: 'INTERNAL' } }, { status: 500 })
  }
}
 
export const POST = composeAPIRoute([mapErrors, authUser, handler])

The discipline: services return ServiceResult for expected outcomes, throw BizError only for truly exceptional flows. Mixing both is a code smell.

Cross-language mirror. Python's @map_platform_exceptions decorator does the identical job โ€” typed PlatformException hierarchy mapped to HTTP at the gateway.

9. Layered Authentication & Authorization

Principle. Auth is three independent layers: authentication (who is this), identity hydration (load profile + permissions), authorization (can they do this). Each layer should be a separate, testable component.

Why it matters. Conflating them produces middleware soup that's impossible to test or reason about. Separated, you can mock identity in tests and add new permission rules without touching JWT parsing.

Next.js mapping.

  • Layer 1 โ€” Authentication. middleware.ts validates the JWT/session cookie, attaches a minimal claim to a request header for downstream handlers.
// middleware.ts
export async function middleware(req: NextRequest) {
  const claim = await verifyToken(req.cookies.get('token')?.value)
  if (!claim && requiresAuth(req.nextUrl.pathname)) {
    return NextResponse.redirect(new URL('/login', req.url))
  }
  const headers = new Headers(req.headers)
  if (claim) headers.set('x-user-id', claim.sub)
  return NextResponse.next({ request: { headers } })
}
  • Layer 2 โ€” Identity hydration. A server util that resolves the full UserContext. The Next.js analog of FastAPI's Depends(get_user_context).
// lib/server/auth/get-user-context.ts
import { cache } from 'react'
import { headers } from 'next/headers'
 
export const getUserContext = cache(async (): Promise<UserContext> => {
  const userId = headers().get('x-user-id')
  if (!userId) throw new AuthError('unauthenticated', 'no token')
  const user = await userRepo.findById(userId)
  const perms = await permissionService.loadFor(userId)
  return { id: user.id, externalId: user.externalId, tenantId: user.tenantId, perms }
})

React.cache deduplicates the call within one request โ€” call it freely from any server component or route handler.

  • Layer 3 โ€” Authorization. Authorization itself decomposes into three permission planes that must be checked together. Treating them as one boolean is how leaks happen.

The three permission planes

PlaneQuestion it answersWhere it livesResolves from
1. Operation (RBAC)"Can this user perform this action at all?"User's role/policyuser.permissions: Set<string> of resource:operation strings, with wildcard support (agent:*, *:*)
2. Resource"Can this user touch this specific object?"The object itselfOwnership, visibility (private / org / public), tenant/org isolation
3. Project / Workspace"Within this project, what can this user do?"Project membershipPer-project role merged with user-level perms via union strategy

A request must clear all three planes to proceed. The planes are independent โ€” RBAC alone doesn't grant access to someone else's private resource; project membership alone doesn't grant operations the user's role forbids.

// shared/contracts/permission.ts
export type Action = `${string}:${string}` // e.g. 'agent:read', 'project:write'
 
export type Resource = {
  id: string
  ownerId: string
  visibility: 'private' | 'org' | 'public'
  orgId: string
  projectId?: string
}
 
export type ProjectMembership = {
  projectId: string
  role: 'viewer' | 'editor' | 'admin'
  perms: Set<Action>
}
 
export type UserContext = {
  id: string
  externalId: string
  orgId: string
  perms: Set<Action>                 // plane 1 โ€” RBAC
  projects: Map<string, ProjectMembership> // plane 3 โ€” per-project memberships
}
// lib/server/auth/can.ts
 
// Plane 1 โ€” operation (RBAC) with wildcard support
function hasOperation(perms: Set<Action>, action: Action): boolean {
  if (perms.has('*:*')) return true
  if (perms.has(action)) return true
  const [resource] = action.split(':')
  return perms.has(`${resource}:*` as Action)
}
 
// Plane 2 โ€” resource (ownership / visibility / org isolation)
function canAccessResource(ctx: UserContext, res: Resource): boolean {
  if (res.orgId !== ctx.orgId) return false                 // org isolation first
  if (res.ownerId === ctx.id) return true                   // owner always passes
  if (res.visibility === 'public') return true
  if (res.visibility === 'org') return res.orgId === ctx.orgId
  return false                                              // private + not owner
}
 
// Plane 3 โ€” project membership (UNION with user-level perms)
function projectGrants(ctx: UserContext, res: Resource, action: Action): boolean {
  if (!res.projectId) return false
  const m = ctx.projects.get(res.projectId)
  if (!m) return false
  return hasOperation(m.perms, action) // membership perms unioned via the same wildcard rules
}
 
// Composed gate โ€” all three planes evaluated together
export function can(ctx: UserContext, action: Action, res?: Resource): boolean {
  if (!res) return hasOperation(ctx.perms, action)
  if (!canAccessResource(ctx, res)) return false           // plane 2 hard-blocks
  return hasOperation(ctx.perms, action) || projectGrants(ctx, res, action) // plane 1 โˆช plane 3
}
 
// Throwing variant for service-layer use
export function authorize(ctx: UserContext, action: Action, res?: Resource): void {
  if (!can(ctx, action, res)) throw new PermissionError('forbidden', `${action} denied`)
}
// service-layer usage โ€” the three planes are invisible to business code
async function publishAgent(id: string, ctx: UserContext): Promise<ServiceResult<Agent>> {
  const agent = await agentRepo.findById(id, ctx.orgId)
  if (!agent) return fail(new NotFoundError('agent', id))
 
  authorize(ctx, 'agent:publish', toResource(agent)) // one call, all three planes
 
  const published = await agentService.publish(agent)
  return ok(published)
}

Why the union strategy on plane 3. A user who is normally a viewer in the org may be an editor in a specific project โ€” their effective perms inside that project are the union of org-level and project-level perms. Intersection (the conservative choice) breaks legitimate collaboration patterns; union with explicit deny rules is the practical default.

Where to enforce each plane:

  • Plane 1 (RBAC) can be checked in middleware/route wrappers for coarse gating (withPermission('agent:read')).
  • Plane 2 (resource) must be checked after loading the entity โ€” it requires the entity's ownerId / visibility / orgId. Always in the service layer.
  • Plane 3 (project) is checked alongside plane 1 inside the service layer for project-scoped resources.

Pitfalls to avoid:

  • Don't check plane 1 alone when an entity is involved โ€” always pair with plane 2.
  • Don't return 404 for "exists but forbidden" unless leaking existence is itself a problem (it often is โ€” prefer 404 for private resources, 403 for org-visible resources).
  • Wildcard discipline. *:* should only ever belong to system/admin tokens, never to regular users. Audit who holds it.
  • Dual-ID pitfall. If your system has both an internal user id and an external id from an upstream IDP, name them explicitly (id vs externalId) and document which one each table foreign-keys to. Project membership and resource ownership often disagree on which id they store โ€” one of the most common silent multi-tenant bugs.

10. Cross-Cutting Concerns via AOP

Principle. Tracing, metrics, usage, rate limiting, logging, and health monitoring are cross-cutting โ€” they apply to every request and must not pollute business code. Compose them transparently via HOFs / decorators / middleware, and treat what you can see as a first-class architectural concern, not a post-hoc add-on.

Why it matters. "Add tracing to every endpoint" without AOP is a 200-file PR and a permanent source of drift. More importantly: an architecture you can't observe is an architecture you can't operate. Logging, tracing, metrics, and health signals are how production tells you the truth.

Next.js mapping.

Composition mechanism

HOF wrappers stack like middleware around routes and server actions. The order matters โ€” outer wrappers see the request first and the response last:

// lib/server/route/compose.ts
export const POST = composeAPIRoute([
  withRequestId(),                                          // generates / propagates request_id
  withTracing('elaborator.create'),                         // opens a span around everything inside
  withRateLimit({ key: 'tenant', limit: 100, window: '1m' }),
  mapErrors,                                                // converts BizError โ†’ HTTP (see ยง8)
  authUser,                                                 // attaches UserContext (see ยง9)
  withUsage('elaborator.create'),                           // emits a usage measure on success
  async (req, ctx) => { /* business */ }
])

Logging โ€” prefixed, structured, PII-safe

Three rules, in order of importance:

  1. Structured. Logs are key-value JSON, never free-text strings to be regexed later. Search, alerting, and aggregation depend on it.
  2. Prefixed by scope. Every logger carries a [Scope] prefix derived from the module โ€” instantly readable in tail/grep, and machine-filterable in log search.
  3. PII-safe. User identifiers, emails, account ids, names are never logged raw in production. Pass them through a sanitizer that hashes/truncates outside dev.
// lib/server/logging.ts
import pino from 'pino'
 
const base = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: {
    paths: ['*.email', '*.password', '*.token', '*.authorization'],
    censor: '[redacted]'
  }
})
 
export function getPrefixedLogger(scope: string) {
  const child = base.child({ scope })
  const fmt = (msg: string) => `[${scope}] ${msg}`
  return {
    info:  (msg: string, meta?: object) => child.info(meta ?? {}, fmt(msg)),
    warn:  (msg: string, meta?: object) => child.warn(meta ?? {}, fmt(msg)),
    error: (msg: string, err?: unknown) => child.error({ err }, fmt(msg)),
    debug: (msg: string, meta?: object) => child.debug(meta ?? {}, fmt(msg))
  }
}
 
// PII helper โ€” mirrors the Python `%p` placeholder convention.
// In dev: pass-through for debuggability. In production: hash + truncate.
export const safe = {
  user:    (id: string) => process.env.NODE_ENV === 'production' ? hash(id).slice(0, 8) : id,
  email:   (e: string)  => process.env.NODE_ENV === 'production' ? e.replace(/(.).+(@.+)/, '$1***$2') : e,
  account: (id: string) => process.env.NODE_ENV === 'production' ? hash(id).slice(0, 8) : id
}
 
// Usage
const logger = getPrefixedLogger('AuthMiddleware')
logger.info(`Token validated successfully for user: ${safe.user(userId)}`, { requestId })
// โ†’ [AuthMiddleware] Token validated successfully for user: a3f9c1b2  {"scope":"AuthMiddleware","requestId":"..."}

Conventions worth enforcing:

  • One logger per module, named by scope: getPrefixedLogger('AgentService'), getPrefixedLogger('ElasticsearchClient').

  • Always include requestId and tenantId in log meta โ€” propagated via AsyncLocalStorage (see ยง12).

  • Standardized levels: debug for development tracing, info for normal events, warn for recoverable anomalies, error for handled exceptions, fatal for service-level failures that page on-call.

  • Unified format documented in CONVENTIONS:

    [2026-04-17T12:00:00Z] [INFO] [AgentService] [request_id=...] message body { ...meta }
  • An eslint-plugin rule (or PR review checklist) flagging console.log in app/ and services/ โ€” the prefixed logger is the only allowed surface.

Tracing โ€” distributed spans around business operations

Initialize OpenTelemetry once in instrumentation.ts (see ยง3). Span names are business-meaningful, not URL paths โ€” agent.publish, elaborator.search, llm.completion, not POST /api/v1/agents/:id/publish.

A withSpan decorator wraps any async function with a span, captures key inputs, and records exceptions:

// lib/server/observability/tracing.ts
import { trace, SpanStatusCode, type SpanOptions } from '@opentelemetry/api'
 
const tracer = trace.getTracer('app')
 
export function withSpan<A extends unknown[], R>(
  name: string,
  fn: (...args: A) => Promise<R>,
  opts: { attrs?: (args: A) => Record<string, string | number | boolean> } = {}
): (...args: A) => Promise<R> {
  return async (...args: A) =>
    tracer.startActiveSpan(name, async (span) => {
      try {
        if (opts.attrs) span.setAttributes(opts.attrs(args))
        const result = await fn(...args)
        span.setStatus({ code: SpanStatusCode.OK })
        return result
      } catch (err) {
        span.recordException(err as Error)
        span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message })
        throw err
      } finally {
        span.end()
      }
    })
}
 
// Usage โ€” wrap a service method with a span and a small set of attributes
export const publishAgent = withSpan(
  'agent.publish',
  async (id: string, ctx: UserContext) => { /* ... */ },
  { attrs: ([id, ctx]) => ({ 'agent.id': id, 'tenant.id': ctx.orgId }) }
)

Discipline:

  • Span names form a stable taxonomy โ€” treat them like an enum (typed SpanName union).
  • Attributes are low-cardinality (tenant.id ok; raw email NOT ok โ€” also a PII issue).
  • Wrap service-layer methods, not utility functions โ€” spans should mean something to a person reading a trace.
  • Propagate traceparent headers through outbound fetch automatically via the OTel HTTP instrumentation.

Metrics โ€” typed enum, never free-form strings

Metrics drift kills dashboards. A typo in the metric name silently produces zero data points and an empty graph. Make the metric name a typed enum and the recording function reject anything else:

// lib/server/observability/metrics.ts
export const Metric = {
  AgentRunDurationMs:     'agent.run.duration_ms',
  AgentRunErrors:         'agent.run.errors',
  LlmTokensIn:            'llm.tokens.in',
  LlmTokensOut:           'llm.tokens.out',
  RateLimitRejects:       'ratelimit.rejects',
  EventLoopLagMs:         'eventloop.lag_ms'
} as const
export type MetricName = typeof Metric[keyof typeof Metric]
 
export function record(name: MetricName, value: number, tags?: Record<string, string>) {
  // Datadog / Prometheus / OTel meter โ€” pick one, hide it here
  meter.histogram(name, value, tags)
}
 
export function increment(name: MetricName, tags?: Record<string, string>) {
  meter.counter(name, 1, tags)
}

Tag discipline. Tags are low-cardinality (tenant_tier, route, model) โ€” never high-cardinality values like raw user ids or full URLs. Each unique tag combination becomes a billed time series.

Health & event-loop monitoring

Async services have a critical health signal that traditional metrics miss: event-loop lag. When the loop is starved (sync CPU work, synchronous fs reads, JSON.parse on huge payloads), every request slows down silently. Monitor it explicitly:

// lib/server/observability/event-loop.ts
import { monitorEventLoopDelay } from 'node:perf_hooks'
import { Metric, record } from './metrics'
 
export function startEventLoopMonitoring(intervalMs = 10_000) {
  const histogram = monitorEventLoopDelay({ resolution: 20 })
  histogram.enable()
  const interval = setInterval(() => {
    const p99 = histogram.percentile(99) / 1e6 // ns โ†’ ms
    record(Metric.EventLoopLagMs, p99)
    if (p99 > 200) logger.warn('event loop lag p99 > 200ms', { p99 })
    histogram.reset()
  }, intervalMs)
  interval.unref()
  return () => { histogram.disable(); clearInterval(interval) }
}

Wire it in instrumentation.ts (ยง3). Alert when p99 exceeds your SLO budget โ€” it's the earliest, cheapest signal that something is wrong on the server.

Other health signals worth wiring at the same time:

  • Active handles / requests count (process._getActiveHandles().length) โ€” leaks show as monotonic growth.
  • Heap usage sampled every minute (process.memoryUsage().heapUsed).
  • Outbound dependency latency p50/p95/p99 (DB, cache, LLM provider).

Usage tracking

Domain-meaningful counters that bill, throttle, or analytics-track an action โ€” emit them as their own primitive, not buried in logs:

export function withUsage(action: string) {
  return async (req: Request, ctx: RouteCtx, next: () => Promise<Response>) => {
    const res = await next()
    if (res.ok) increment(Metric.AgentRunDurationMs, { action, tenant: ctx.user.orgId })
    return res
  }
}

11. Async Patterns & Background Work

Principle. Make the lifecycle of async work explicit: what blocks the response, what runs in the background, what's queued for later, what's offloaded to a worker.

Why it matters. Implicit Promises that nobody awaits leak request context, swallow errors, and quietly bill you for orphaned compute on serverless. Explicit lifecycle = predictable behavior.

Next.js mapping.

  • Defer past response. Use after() (App Router) for analytics, audit logs, cache warming โ€” anything the user doesn't need to wait for:
import { after } from 'next/server'
 
export async function POST(req: Request) {
  const result = await elaboratorService.create(...)
  after(async () => {
    await analytics.track('elaborator.created', { id: result.data.id })
    await cache.warm(`elaborator:${result.data.id}`)
  })
  return Response.json(result)
}
  • Edge deferred work. waitUntil(promise) from the runtime โ€” the platform keeps the function alive long enough for the promise to resolve.
  • Fire-and-forget hooks. When you do need to launch many background tasks, gather them with Promise.allSettled so one failure doesn't poison the rest. Never drop a bare promise.
const results = await Promise.allSettled(hooks.map((h) => h.run(event)))
results.forEach((r, i) => {
  if (r.status === 'rejected') logger.error(`hook[${i}] failed`, r.reason)
})
  • CPU offload. Heavy work (token counting, large JSON parsing, image processing) โ†’ worker_threads on node, Web Worker on client, WASM for hot paths (tiktoken-wasm, image-rs).
  • Queues. For long-running jobs that outlive a request โ€” BullMQ (self-hosted Redis), QStash (managed), or your platform's queue. The producer is in your service layer; the consumer runs in a separate process.

Cross-language mirror. FastAPI services use asyncio.create_task() + gather(return_exceptions=True) for fire-and-forget, and loop.run_in_loop() for CPU offload. Same patterns.

12. Multi-Tenancy & State Isolation

Principle. If your system serves multiple customers, tenant context is part of every operation. Bake it into the bottom layers (repository, cache, queue) so that forgetting it at the call site fails closed, not open.

Why it matters. Multi-tenancy bugs are the worst class of bug โ€” silent data leakage across customers. Defense in depth: validate at auth, enforce at repository, namespace at cache.

Next.js mapping.

  • Propagate via AsyncLocalStorage. A request-scoped store carries tenantId so deep call sites don't need to pass it explicitly:
// lib/server/context/request-context.ts
import { AsyncLocalStorage } from 'node:async_hooks'
 
type RequestContext = { tenantId: string; userId: string; requestId: string }
const storage = new AsyncLocalStorage<RequestContext>()
 
export const runWithContext = <T>(ctx: RequestContext, fn: () => T) => storage.run(ctx, fn)
export const getContext = (): RequestContext => {
  const ctx = storage.getStore()
  if (!ctx) throw new Error('request context missing โ€” wrap in runWithContext')
  return ctx
}
  • Repository enforcement. The base repo always reads tenantId from context and adds it to every query. A test that tries to read across tenants should fail loudly.
  • Cache namespacing. All Redis keys prefixed with tenant:{id}: โ€” never share a flat keyspace.
  • Per-tenant rate limits. Identity for rate limit key = tenant:{id} (not user) for fairness across a tenant's users.
  • Per-tenant overrides. Feature flags keyed on tenant id flow naturally from ยง2's dynamic tier.

Cross-language mirror. FastAPI platform services use ES account_routing for index isolation and pass UserContext explicitly; the discipline is identical even when the mechanism differs.


Part II โ€” The Next.js-Specific Surface

The 12 concerns above transfer between languages. The rest of this part covers what's specific to Next.js โ€” features without a clean cross-language analog.

You can install next.js BP skill from Vercel Official Skills Hub to instruct AI for BP. ๐Ÿ—ž๏ธ

Controller Layer (Routes / Server Actions / Pages)

The Controller is a thin transport adapter โ€” its job is composition, not business logic. With the IO contract from ยง4 and the AOP wrappers from ยง10, controllers stay short:

// API Route
export const POST = composeAPIRoute([
  mapErrors,
  withTracing('elaborator.create'),
  authUser,
  authBizLogic,
  async (req, res, ctx) => {
    return Response.json(await elaboratorService.create(await req.json(), ctx.user))
  }
])
 
// Page Server
export const getServerSideProps = composePageServer(
  function (params, ctx) {
    return <div>...</div>
  },
  [authUserInPage, authBizLogicInPage]
)
 
// Server Action
export const updateElaborator = composeServerAction([
  authUserAction,
  authBizLogicAction,
  async (input, ctx) => elaboratorService.update(input, ctx.user)
])

Reference implementation: arno-packages/server/next.

Conventions worth adopting:

  • Private folders prefixed with _ (e.g. _components/) are not routed โ€” perfect for co-located components and helpers.
  • Group routes with (groupName) to slice by feature/module without affecting URLs.
  • Use Next.js conventions: default, layout, loading, error, not-found, sitemap, robots, and the upcoming forbidden and cache directive conventions.
  • Version public APIs (/api/v1/*) and document with OpenAPI/Swagger.
  • Prefer Restful API or GraphQL API over RPC API.
  • Use gRPC or protobuf based for dedicated scenarios for simple app, it's not a good choice.
  • Zero business logic at this layer. If the controller has more than a few lines of orchestration, push it into the service.

Service & Manager Layers in Detail

The Service layer is where domain logic lives. A typical service:

export class ElaboratorService {
  private prisma = prismaClient
 
  constructor() {}
 
  @WithServiceResult()
  async updateElaborator(data: Prisma.ElaboratorUpdateInput) {
    return (await this.prisma.elaborator.update({
      where: { id: data.id as bigint },
      data: data
    })) as any as ServiceResult<Elaborator>
  }
}
  • Apply Domain-Driven Design where the domain warrants it โ€” for CRUD-heavy admin, the module convention from ยง6 is enough.
  • Services return ServiceResult; they do not throw for expected outcomes (see ยง4 + ยง8).
  • Use interface-oriented design for swappable implementations (*.service.impl.ts).
  • Apply DI for testability โ€” a small DI container (tsyringe, inversify) or a factory module is enough; you rarely need a heavyweight framework.

The Manager layer holds reusable business logic that's narrower than a service โ€” think calculators, formatters, event-pub/sub, log managers. Managers may throw; services catch. Place utils/ and helpers/ as a sub-category of manager.

Data Persistence Strategies

  • Pick SQL or NoSQL deliberately based on access patterns. Most products start fine with Postgres + Prisma.
  • Plan for scale before you need it โ€” at >1M rows think about indexing + query plans, at >10M think about sharding/partitioning, at >100M consider a different storage entirely.
  • Serverless DBs (PlanetScale, Neon, Turso) reduce ops burden for most teams.
  • Supabase is great for prototyping; evaluate carefully before relying on it in production.
  • Always: transactions for invariants, replication for durability, backups you've actually restored from, and indexes guided by the slow query log โ€” not guessed.

Make full use of DB design and optimization strategies, such as:

  • use transaction and isolation to handle the data consistency and integrity for sensitive data and operations use sharding and partition to handle the data scaling and performance
  • use replication and backup to handle the data redundancy and backup
  • use index and query optimization to handle the data query and performance
  • use schema and migration to handle the data schema and data structure
  • use replication and backup to handle the data redundancy and backup

Next.js Framework Best Practices

  • Turbopack for faster dev.
  • Layered cache system โ€” read Next.js Cache carefully and combine React.cache, fetch cache, route cache, client cache, and external Redis intentionally.

layered-cache-of-nextjs

  • Edge runtime for static + simple fetch + middleware-style work; Node runtime for heavy deps and long-lived clients.
  • WASM (e.g. tiktoken-wasm) for performance-critical computation.
  • Server Components vs Client Components is a progressive enhancement axis:
    • Server Components: fast first paint, SEO-friendly, content-first pages.
    • Client Components: interactive surfaces, web-app shells.
    • Combine deliberately โ€” most pages are mostly server with interactive islands.
  • Use the framework's built-in optimizers: next/image, next/script, next/link, next/font.
  • SEO: metadata API, robots.ts, sitemap.ts, Open Graph + Twitter cards, integrations with Search Console / Analytics / Tag Manager.
  • Performance monitoring with web-vitals.
  • dynamic() + Suspense + React.lazy for on-demand chunks.
  • Separate server/ / client/ / shared/ folders so the runtime is obvious from the import path.
  • Audit next.config.js end-to-end before production.
  • Server Actions first โ€” avoid duplicate API route declarations when client and server can speak directly.
  • Use middleware sparingly โ€” i18n, auth, analytics, redirects, CORS โ€” but don't make it your whole controller layer.

Server Actions FIRST!

Other official guides worth knowing: auth, analytics, ci-building, i18n.

.
โ”œโ”€โ”€ app
โ”œโ”€โ”€ components
โ”œโ”€โ”€ configs
โ”œโ”€โ”€ data
โ”œโ”€โ”€ tools
โ”œโ”€โ”€ i18n
โ”œโ”€โ”€ lib
โ”œโ”€โ”€ prisma
โ”œโ”€โ”€ public
โ”œโ”€โ”€ services
โ”œโ”€โ”€ tailwind.config.ts
โ”œโ”€โ”€ tsconfig.json
โ”œโ”€โ”€ docs             -> AI Era: system instructions for prd, tech, product, project
โ””โ”€โ”€ types

Layered by purpose:

  • Base functionality: public, prisma
  • Global shared: tools, types, configs, i18n
  • Shared logic: lib, services, components
  • Application logic: app

Detailed structure:

- `app`: the main app entry of next.js app
  - `[bizRoute]`: the business route
    - `page.tsx`: the main page of the route
    - other nextjs conventions named files
    - `*.server.tsx`: biz server component
    - `*.client.tsx`: biz client component
  - `api`: the api routes
    - `/v1`: the versioned api routes for public use
      - `*.ts`: the api routes
- `components`: the shared components
  - `server` / `client` / `shared`
- `lib`: the shared lib and tools
  - `server` / `client` / `shared`
    - `*.manager.ts`: the biz manager that can be shared
- `services`: the shared services
  - `server` / `client` / `shared`
    - `*.service.ts`: the biz service that can be shared
- `public`: the public static files

Or โ€” combining with the module convention from ยง6 โ€” group by business module:

elaborator/
โ”œโ”€โ”€ client/
โ”‚   โ”œโ”€โ”€ flow.manager.ts
โ”‚   โ””โ”€โ”€ position.manager.tsx
โ”œโ”€โ”€ server/
โ”‚   โ””โ”€โ”€ elaborator.base.task.ts
โ”œโ”€โ”€ shared/
โ”‚   โ”œโ”€โ”€ elaborator.diagram.manager.ts
โ”‚   โ”œโ”€โ”€ elaborator.enum.ts
โ”‚   โ””โ”€โ”€ elaborator.type.ts
โ””โ”€โ”€ tasks/
    โ”œโ”€โ”€ edoc-generator/
    โ”‚   โ””โ”€โ”€ doc-generator.server.ts
    โ”œโ”€โ”€ mental-framework-generator/
    โ”‚   โ””โ”€โ”€ mental-framework-generator.server.ts
    โ””โ”€โ”€ problem-outline-generator/
        โ”œโ”€โ”€ problem-outline-generator.server.ts
        โ””โ”€โ”€ problem-outline.node.client.tsx

Keep file names tight โ€” the path is the context.

Reference: arno-packages.

Web Client Architecture

Companion guides: React Best Practice, React State Management.

Separate logic and view.

  • JSX/components stay functional and presentational.
  • No business logic chunks (handlers, callbacks, hooks) inline in JSX โ€” call into a client manager or service.
  • Prefer Server Actions to thread server logic in cleanly.

Layer your state.

  • Server state: URL params, pathname, route โ€” and remote data via SWR / TanStack Query, or fetched directly in Server Components.
  • Shared context (rarely mutates): React.Context for theme, locale, user.
  • Shared state (mutates often, cross-component): redux / zustand / recoil with logger + middleware.
  • Reactive state (event-driven, streams): RxJS / Mobx.
  • Local state: useState / useReducer.

Hook-style first โ€” encapsulate logic in hooks, keep components small.

Design the data flow โ€” explicitly choose what fetches in RSC vs. client; never fetch the same thing twice.

Logic encapsulation.

  • Client-side services mirror server services โ€” they encapsulate APIs, swallow expected errors, return uniform results.
  • Client-side managers are atomic business primitives โ€” they may throw, services catch.

Performance, Storage & Network

  • Web Workers for heavy compute on the main thread budget.
  • Rust/WASM for the hottest paths.
  • Service Workers / PWA for offline-capable apps.
  • IndexedDB (via Dexie / idb-keyval) wrapped in a manager for local persistence.
  • localStorage / sessionStorage for preferences and short-lived caches.
  • Native fetch first โ€” avoid axios unless you genuinely need its features.
  • HTTP streams via Fetch API for AI-era data; pair with ai-sdk streaming and RSC streaming for end-to-end streaming UIs.
  • WebRTC / WebSockets for real-time and presence.

UI & UX

  • tailwindCSS โ€” AI-friendly, fast iteration, no extra files.
  • shadcn/ui, AntD, or MaterialUI with a customized theme for components with non-trivial behavior.
  • Storybook for visual testing of components.
  • Cross-viewport and cross-version compatibility from day one.
  • Mobile-web fundamentals: responsive design, dark mode, A11Y, I18N, motion.
  • Apply the design guides from Google / Apple / Microsoft / Mozilla / W3C.
  • Tasteful animation makes the UI feel alive โ€” but never block interaction.
  • Pick a rendering strategy per route:
    • SSG for content that's stable
    • SSR for personalized + SEO-required
    • ISR for content that updates on a schedule
    • CSR for app shells

Domain-Specific Technology

Don't reinvent โ€” use the canonical libraries:

  • Text/code editors, formatters, linters
  • Charts, graphs, data visualization
  • Maps, geo
  • Real-time collaboration
  • 3D / 2D rendering, animation
  • Audio / video / media
  • WebXR / VR / AR

Ask AI for the current best-of-breed in your domain โ€” these change yearly.

Make Full Use of Libraries, Tools, Services, Platforms

Avoid reinventing the wheel. Use the best of the Node / JavaScript ecosystem.

  • ai-sdk for LLM, tools, agents.
  • Prisma for the DB layer; or a vendor SDK for managed services.
  • Jest for unit/integration; Playwright for E2E.
  • Sentry / Raygun for error tracking and traces.
  • SLS / ELK for centralized logs.
  • GlobalRef for long-lived node-runtime singletons (see ยง3).
  • Tests on basic libs and key business logic โ€” not on every line.
  • EdgeConfig (or equivalent) for synced config across instances (see ยง2).
  • Redis / Vercel KV for shared cache and session.
  • A/B testing or gray-release service for feature rollouts.
  • Message queues for async at scale.

DevOps & Deployment

  • Serverless first โ€” skip K8s until you have a real reason. Lower complexity, lower cost, faster iteration.
  • GitHub Actions for CI/CD on small/medium scale.
  • Vercel is the obvious default for Next.js โ€” especially for MVP/PMF.
  • Other supported platforms and scenarios when you need them.

Part III โ€” Two Philosophies: Composition vs. Convention

A mature Next.js codebase tends to split internally between two distinct styles. Recognizing the split โ€” and choosing deliberately โ€” is more useful than picking one.

Composition over Convention โ€” when openness wins

Use composition when the domain is open-ended and growing in unpredictable directions.

  • AI agents โ€” tools, prompts, models, guardrails composed per persona.
  • Server Actions + AI SDK โ€” assembling streaming pipelines per feature.
  • Marketing site โ€” every page is bespoke, no shared shape.
  • Indie / MVP โ€” you don't yet know which patterns will repeat.

Each new capability is its own composition. The cost: every new feature is an architectural micro-decision. The benefit: maximum flexibility, no Procrustean fit.

Convention over Composition โ€” when scale wins

Use convention when the domain has many similar things and you want to remove decisions.

  • CRUD-heavy admin platforms โ€” 30+ resources that all need the same lifecycle, validation, audit trail.
  • Multi-tenant SaaS โ€” every entity needs the same tenant scoping, locking, permissions.
  • Internal tools at a company โ€” consistency across teams matters more than per-team optimization.

The 4-file module convention from ยง6, the BaseEntityHandler from ยง5, the BaseRepository from ยง7 โ€” these are convention-heavy patterns. They pay back at 10+ resources, feel like overhead at 2-3.

Picking per slice, not per project

A real codebase usually has both โ€” a CRUD-heavy services/platform/* slice that uses convention, and a services/agents/* slice that uses composition. Don't try to force one style onto the whole codebase. Pick the style that matches the slice's shape.


Part IV โ€” Patterns Worth Extracting

A transferable checklist for any new Next.js service:

  1. Layered architecture with a strict downward dependency rule, enforced by dependency-cruiser or eslint-plugin-boundaries (ยง1).
  2. 3-tier configuration: zod-validated env, composable per-feature config, dynamic feature flags (ยง2).
  3. Lifecycle hooks via instrumentation.ts + globalThis singletons + after() / waitUntil() (ยง3).
  4. ServiceResult<T> IO contract flowing through routes, server actions, services, and the client (ยง4).
  5. BaseEntityHandler<T> lifecycle hooks + Prisma optimistic locking + typed state machine (ยง5).
  6. Per-resource module convention: <resource>.{service,handler,repository,validator,types,route}.ts (ยง6).
  7. BaseRepository<T> with tenant routing baked in and overridable serialize/deserialize (ยง7).
  8. Typed BizError hierarchy + mapErrors middleware mapping to HTTP at the transport edge (ยง8).
  9. Three-layer auth + three-plane permissions: middleware.ts (authn) โ†’ getUserContext() (identity) โ†’ can() resolver evaluating operation (RBAC) โˆง resource (ownership/visibility/org) โˆง project (membership union) (ยง9).
  10. Observable by construction: HOF-composed withSpan / withUsage / withRateLimit, getPrefixedLogger(scope) with PII redaction + safe.* helpers, typed Metric enum, and event-loop lag (monitorEventLoopDelay) as a first-class health signal (ยง10).
  11. Explicit async lifecycle: after(), Promise.allSettled for fire-and-forget, queues for long jobs (ยง11).
  12. Multi-tenant defense in depth: AsyncLocalStorage context, repository enforcement, cache namespacing (ยง12).

  1. stateless architecture for serverless deployment.
  2. OO design principles for some scenarios where it's more appropriate. Apply the Rules of OO BP, such as SOLID, DRY, KISS, etc. if necessary.
  3. design patterns is recommended to Next.js too, with IoC, AOP, Facade, etc.

Conventions

Error Handling Patterns

Zero error handling is the best error handling. Since we can't avoid errors, handle them in one place, elegantly.

  • Low-level APIs throw less; high-level APIs handle.
  • One place to handle, one place to log โ€” no duplicate handlers.
  • Use the native Error (or BizError subclass from ยง8) on both server and client. Don't grow a parallel error model.
  • Unified log format:
[2024-03-12 12:00:00] [ERROR level] [ServiceName] [ErrorTypeCode] [ErrorMsg]
[?ErrorStack] if necessary
  • HTTP responses use status + body code + body message โ€” not 200-with-error-in-body.
  • warn / error / fatal levels โ€” fatal goes to the alerting log service.
  • try / catch at the service/manager seam โ€” keep the catch block as small as possible.
  • ErrorBoundary on the client for fallback UI โ€” see error-boundary.tsx.

File Types and Conventions

  • *.type.ts โ€” shared TS types
  • *.constant.ts โ€” shared constants
  • *.util.ts โ€” shared utilities
  • *.manager.ts โ€” shared business manager
  • *.manager.impl.ts โ€” abstract manager implementation
  • *.service.ts โ€” shared business service
  • *.service.impl.ts โ€” abstract service implementation
  • *.handler.ts โ€” entity lifecycle handler (ยง5)
  • *.repository.ts โ€” data access (ยง7)
  • *.validator.ts โ€” input validation
  • *.store.ts โ€” zustand or other client state store
  • *.hook.ts โ€” shared business hook
  • *.client.tsx โ€” client component
  • *.server.tsx โ€” server component
  • *.server.action.ts โ€” server action shared across server and client
  • *.shared.tsx โ€” runtime-agnostic shared component
  • *.[designPatternName].*.ts โ€” design pattern implementation, e.g. account.adapter.impl.ts

Class & Method Naming

  • Verb first, noun second: createUser, getUser, updateUser, deleteUser.
  • CRUD prefixes: create, get / list, update, delete.
  • Common operations: search, fetch, count, aggregate.
  • State transitions: init, start, stop, pause, resume.
  • Batch ops: batch, bulk, multi.

Reference

Related articles:

Notes

  • 2024-02-02: initial version
  • 2024-03-12: bugfix and add more details for some topics, add db related topics guide
  • 2024-03-29: add directory naming conventions for components
  • 2024-10-21: upgrade recent server-action bp and add more details for the recent experience accumulated
  • 2025-05-13: keep updated with latest knowledge and version of next.js and other related technologies
  • 2025-05-29: database related topics and best practices updated, add more practical experience in AI Era
  • 2026-04-17: major restructure โ€” reorganized as a principles-first guide around twelve universal architecture concerns (dependency rule, tiered config, lifecycle, IO contract, entity hooks, module convention, generic repository, typed exceptions, layered auth, AOP, async patterns, multi-tenancy), translated from cross-language backend best practices into concrete Next.js mappings; added composition-vs-convention philosophy and a transferable patterns checklist.

Crafting:


Arno Crafting Apps

ELABORATION STUDIO ๐Ÿฆ„

Elaborate your ideas and solve your problems with AI in fully boosted context way ~