Architecture
A NestJS backend (PostgreSQL) and a Next.js 16 frontend, sharing one REST + OpenAPI contract. The backend owns data and business logic. The frontend owns UI, sessions, and magic-link email. They coordinate over HTTP: no shared database client, no shared process state.
High-Level Overview
┌──────────────────────┐
│ Browser / Client │
└──────────┬───────────┘
│ HTTPS
v
┌────────────────────────────────────────────┐
│ Next.js 16 frontend (port 3000) │
│ │
│ RSC + Server Actions │
│ NextAuth v5 (database session strategy) │
│ Custom BackendAdapter (lib/auth-adapter) │
│ Magic-link Nodemailer provider (sync) │
│ │
│ lib/api.ts │
│ api (public, no auth) │
│ serverApi() (server-only, Bearer │
│ from httpOnly cookie)│
│ internalApi() (server-only, X-Tok) │
└──────┬──────────────────────────┬──────────┘
│ │
Bearer session token X-Internal-Token
(read server-side from (server-only, shared bearer)
httpOnly cookie)
│ │
v v
┌────────────────────────────────────────────┐
│ NestJS backend (port 4000) │
│ PostgreSQL via Knex + pg │
│ │
│ REST + OpenAPI surface. │
│ /openapi.json (always) · /docs (dev only) │
│ │
│ Public: /items, /blog/*, /users/* │
│ Admin: /admin/* │
│ Internal: /internal/auth-adapter/* │
│ Health: GET / │
│ MCP: POST /admin/mcp (stateless) │
│ │
│ Workers (cron): EmailProcessor, │
│ UserDeletionWorker │
└──────────────────────┬─────────────────────┘
│
v
┌──────────────────────┐
│ PostgreSQL │
│ │
│ users, sessions, │
│ accounts, │
│ verification_ │
│ tokens, │
│ items, blog_posts, │
│ email_jobs, │
│ user_deletion_ │
│ queue │
└──────────────────────┘
The browser only ever talks to the frontend. Calls to the backend go server-side: from a Server Component, from a Server Action, or from the NextAuth adapter. The session token and the X-Internal-Token never reach client-side JavaScript.
How the Two Projects Connect
There are three HTTP channels between frontend and backend, each with its own purpose and auth shape:
- Public REST (
NEXT_PUBLIC_API_URL, defaulthttp://localhost:4000). Unauthenticated reads (GET /blog/posts,GET /items, etc.). Used bylib/api.ts'sapiclient. Safe to call from anywhere, but in this template we only call it from RSC. - Authed REST (same base URL,
Authorization: Bearer <session-token>). Authenticated reads + mutations (POST /items,POST /users/me/delete-account,GET /admin/*). Used byserverApi()from RSC and Server Actions.serverApi()reads the NextAuth httpOnly session cookie vianext/headersand forwards it as a Bearer header. The token never reaches client JavaScript. - Internal REST (
INTERNAL_API_URL+X-Internal-Token). NextAuth adapter operations:/internal/auth-adapter/*. 14 endpoints covering every method the NextAuth Adapter contract requires (createUser,getSessionAndUser,useVerificationToken, etc.). Used byinternalApi()and locked behindInternalApiTokenGuardon the backend (timing-safe compare againstINTERNAL_API_TOKEN).
The OpenAPI spec at template/openapi.json is the canonical contract. The backend emits the spec at boot; CI fails on drift against the committed snapshot. The frontend regenerates types/api.d.ts from this file via yarn generate-types; the same CI step also fails if the generated file is stale.
Magic-link email split
NextAuth's email sign-in has two halves: storage (write a verification token, atomically consume it on click) and send (deliver the email). They live on different sides on purpose:
- Storage is delegated to the backend through the adapter:
createVerificationTokenwrites viaPOST /internal/auth-adapter/verification-tokens,useVerificationTokenconsumes atomically viaPOST /internal/auth-adapter/verification-tokens/use. - Send stays on the frontend: NextAuth's Nodemailer provider in
frontend/auth.tscreates the SMTP transport and sends synchronously inside the request that initiated sign-in. Latency matters here, and a queue would be a regression.
Bulk and transactional emails (campaigns, account-deletion notifications) are different: they go through the backend queue (EmailService.queue / EmailService.queueBulk) and the cron-driven worker. Both halves use the same SMTP server in dev (MailDev), but the configurations are independent.
Backend Module Structure
NestJS organizes code into feature modules. Each owns a controller (REST routes), service (data access + business logic), and a *.schemas.ts file (zod schemas + DTO classes via nestjs-zod's createZodDto).
src/
app.module.ts # Root - imports all feature modules
app.controller.ts # Health check (GET /)
main.ts # Bootstrap: secret validation, CORS, Swagger,
# /openapi.json, optional /docs UI
db/ # Row interfaces in db/types.ts
# Migrations in ../migrations/
auth/
auth.module.ts # Exports AuthService + guards
auth.service.ts # validateSession(token) -> user
auth.guard.ts # SessionGuard (required Bearer auth)
internal-api-token.guard.ts # X-Internal-Token, HMAC-then-timingSafeEqual
session-cleanup.worker.ts # hourly TTL purge for sessions + verification_tokens
decorators.ts # @CurrentUser() param decorator
common/
pagination.ts # parseOffset, clampLimit (max 200)
internal/
internal.module.ts
auth-adapter.service.ts # 14 NextAuth adapter methods
internal-auth-adapter.controller.ts # POST/GET/PATCH/DELETE /internal/auth-adapter/*
auth-adapter.schemas.ts # zod + DTOs for all adapter bodies/responses
users/
users.controller.ts # POST /users/me/delete-account (auth)
users.service.ts # findById, ghostDelete, queueDeletion, ...
user-deletion.worker.ts # cron worker: ghost-delete queue
users.schemas.ts
items/ # Example CRUD module
items.controller.ts # GET (public), POST/PATCH/DELETE (auth + ownership)
items.service.ts
items.schemas.ts # ItemSchema, CreateItemSchema, UpdateItemSchema, DTOs
blog/
blog.controller.ts # Public: GET /blog/posts, /blog/posts/:slug, /blog/tags
admin-blog.controller.ts # Admin: GET/POST/PATCH/DELETE /admin/blog/posts
blog.service.ts # CRUD, tags, view tracking, reading time
blog.schemas.ts
admin/
admin.controller.ts # GET /admin/users (paginated)
admin.guard.ts # AdminGuard (req.user.isAdmin)
admin.service.ts # User management
admin.schemas.ts
email/
admin-email.controller.ts # GET /admin/email/stats, /admin/email/campaigns/:id
email.service.ts # send / queue / queueBulk + processBatch
email.processor.ts # @Cron: runs processBatch every 30s
email.schemas.ts
billing/ # Stripe billing (optional-by-config)
billing.controller.ts # /billing/status|me|checkout|portal
billing.service.ts # Stripe client, Checkout/Portal, getEntitlement
billing-webhook.service.ts # Guarded upsert state machine
stripe-webhook.controller.ts # POST /webhooks/stripe (raw body, excluded from OpenAPI)
billing-events.service.ts # Webhook audit log
billing-status.ts # Status mapping + grantsPaidAccess
mcp-admin/ # AI-agent admin interface (MCP protocol)
mcp-admin.controller.ts # POST /admin/mcp (excluded from OpenAPI; GET/DELETE → 405)
mcp-admin.service.ts # API-key validation + tool registration
mcp-admin-tools.service.ts # Tool registration (admin_get_blog_posts, ...)
mcp-admin-user.constants.ts # MCP_ADMIN_USER_ID, MCP_ADMIN_USER_EMAIL
# (sentinel: mcp-admin@invalid.crabstack.local)
mcp-shared/
mcp-stateless.ts # Stateless transport: throwaway server per request
mcp-oauth.factory.ts # OAuth 2.1 + DCR + PKCE controllers (opt-in per server)
mcp-controller.utils.ts # Shared HTTP helpers
mcp-rate-limit.ts # IP-based rate limit for MCP endpoint
mcp-tools.utils.ts # safeTool wrapper + tool annotations
server-info-tool.ts # Shared get_server_info discovery tool
scripts/
seed.ts # Database seed
Frontend Structure
frontend/
auth.ts # NextAuth setup: BackendAdapter, providers,
# callbacks, session strategy: 'database',
# trustHost, magic-link rate limit
auth.config.ts # Edge-safe slice (OAuth providers + pages)
proxy.ts # /backoffice/* admin gate + per-request CSP nonce
app/
layout.tsx # Root layout
providers.tsx # SessionProvider (no Apollo, no SWR)
page.tsx # Marketing
blog/ # Public blog (RSC, ISR via revalidate)
signin/ # Magic-link + OAuth UI
backoffice/ # Admin dashboard (server-gated)
settings/ # Account settings
api/auth/ # NextAuth route handler
lib/
api.ts # openapi-fetch clients:
# api : public, no auth (browser-safe)
# serverApi() : async, server-only; reads
# NextAuth cookie via next/headers
# and forwards as Bearer
# internalApi() : server-only, X-Internal-Token
# Plus DTO type re-exports
auth-adapter.ts # BackendAdapter() : 14 methods, all delegate to
# /internal/auth-adapter/* via internalApi()
blog.ts # RSC helpers: getBlogPosts, getBlogPost
types/
api.d.ts # Generated by `yarn generate-types`
next-auth.d.ts # session.sessionToken augmentation (server-only use)
REST Surface
A short tour of the contract. Full schema bodies live in template/openapi.json.
| Path | Method | Auth | Purpose |
|---|---|---|---|
/ |
GET | none | Health check |
/items |
GET | none | List items (public) |
/items |
POST | session | Create item (caller becomes createdBy) |
/items/:id |
PATCH | session + owner/admin | Update |
/items/:id |
DELETE | session + owner/admin | Delete |
/blog/posts |
GET | none | Paginated published posts (offset, limit, optional tag) |
/blog/posts/:slug |
GET | none | One published post (bumps view count) |
/blog/tags |
GET | none | Distinct tags |
/users/me/delete-account |
POST | session | Queue account deletion (worker ghosts asynchronously) |
/admin/users |
GET | session + admin | Paginated user list |
/admin/blog/posts |
GET/POST/PATCH/DELETE | session + admin | Full blog CRUD (drafts included) |
/admin/email/stats |
GET | session + admin | Aggregate email queue stats |
/admin/email/campaigns/:id |
GET | session + admin | Per-campaign breakdown |
/internal/auth-adapter/* |
varies | X-Internal-Token |
14 NextAuth adapter routes |
/billing/status |
GET | Public | Whether billing is configured on this deploy |
/billing/me, /billing/checkout, /billing/portal |
GET/POST | Session | Entitlement + Stripe Checkout/Portal redirects |
/webhooks/stripe |
POST | Stripe signature | Billing webhook (raw body; excluded from OpenAPI) |
/admin/mcp |
POST | API key | Stateless MCP transport (excluded from OpenAPI; GET/DELETE → 405) |
Pagination is uniform: ?offset=0&limit=50 with hard cap 200. Responses for list endpoints are { items: T[]; total: number }.
Guards
REST guards live in src/auth/:
| Guard | Behavior |
|---|---|
SessionGuard |
Reads Authorization: Bearer <token>, calls AuthService.validateSession, sets req.user. Throws 401 on miss. |
AdminGuard |
Stack after SessionGuard. Reads req.user.isAdmin. Throws 403 on miss. |
InternalApiTokenGuard |
Reads X-Internal-Token, hashes both sides and compares with timingSafeEqual against INTERNAL_API_TOKEN. Used on the entire InternalAuthAdapterController. |
Stack admin endpoints with @UseGuards(SessionGuard, AdminGuard).
Worker Concurrency & Recovery
Cron-driven workers (EmailProcessor, UserDeletionWorker) poll their queue tables every 30 seconds (emails) or every minute (deletions). To stay safe across multiple replicas without a Redis or BullMQ dependency, every worker uses a per-row claim with TTL recovery pattern:
- The claim is atomic. Workers issue
UPDATE ... WHERE id IN (SELECT id ... FOR UPDATE SKIP LOCKED) RETURNING *, which lets exactly one worker win each row. - The claim filter is two-arm: it picks up
pending(or retryablefailed) rows OR rows inprocessingwhoseclaimed_atis older thanSTALE_MS. This recovers a row whose worker died holding the claim, without a separate orphan reaper. STALE_MSdefaults to 5 minutes. Override with theWORKER_CLAIM_STALE_MSenv var (milliseconds) if you need shorter / longer recovery windows.- On per-item failure, workers reset the row to
status='pending'withclaimed_at=nullso the next cycle retries immediately. Email jobs additionally honorattempts < maxAttemptsandnextRetryAtfor exponential backoff.
This rules out double-sends across replicas during normal operation, and bounds the worst-case "stuck claim" window to STALE_MS. It does not eliminate the possibility of an at-least-once double-process if a worker dies and its claimed row's TTL expires before the original work was actually completed at SMTP. At-least-once is the explicit contract here.
DTO Extractor Pattern
Every module follows the same shape: a single *.schemas.ts file holds zod schemas, the createZodDto-wrapped DTO classes, and an extract*FromRow helper at the bottom. Services return raw rows; controllers call the extractor before returning. See Adding a Module for a walkthrough.
Related
- Home - Project overview
- Auth Flow - Full authentication walkthrough
- Adding a Module - Step-by-step REST + zod walkthrough
- Email - Email service details
- Environment Variables - Configuration reference