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 │
│ auth: users, sessions, │
│ accounts, │
│ verification_tokens│
│ app: items, blog_posts │
│ queues: email_jobs, │
│ user_deletion_queue│
│ billing: 4 tables, see │
│ docs/Billing.md │
└──────────────────────────────┘
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.json file at the repository root 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 splits across the two processes on purpose. Storage is delegated to the backend through the adapter (createVerificationToken writes, useVerificationToken consumes atomically). Send stays on the frontend, where NextAuth's Nodemailer provider sends synchronously inside the request that initiated sign-in: latency matters there, and a queue would be a regression.
Bulk and transactional email is the other path entirely: the backend queue plus its cron worker. Both are covered in Email.
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 every feature module
main.ts # Bootstrap: secret validation, CORS, /openapi.json, /docs (dev)
db/ # Row interfaces (types.ts) + integration-spec gating.
# Migrations live in ../migrations/
knex/ # KNEX_CONNECTION provider, global
common/ # pagination (parseOffset, clampLimit, max 200),
# body-parsers (raw for Stripe), worker-claim, common.schemas
auth/ # AuthService.validateSession, SessionGuard,
# InternalApiTokenGuard, @CurrentUser(),
# session-cleanup.worker (hourly TTL + audit-table sweep)
internal/ # The 14 NextAuth adapter endpoints, X-Internal-Token only
rate-limit/ # DB-backed magic-link limiter + its internal endpoint
users/ # Account deletion: ghostDelete, queue, user-deletion.worker
items/ # Example CRUD module - copy this shape
blog/ # Public read controller + admin CRUD controller
admin/ # AdminGuard + GET /admin/users
email/ # send / queue / queueBulk, @Cron processor, admin stats
billing/ # Stripe, optional-by-config. billing-subscription-store.ts
# is the ONLY writer of billing state (disputed lock,
# event cursor). stripe-webhook.controller takes a raw
# body and is excluded from OpenAPI. See docs/Billing.md
mcp-admin/ # AI-agent admin surface: POST /admin/mcp, tool registration
mcp-shared/ # Reusable stateless transport, OAuth 2.1 factory, rate
# limits, safeTool. See docs/Admin MCP Server.md
scripts/seed.ts # Database seed
Frontend Structure
frontend/
auth.ts # NextAuth: BackendAdapter, providers, session callback,
# strategy 'database', production env assertions
auth.config.ts # Edge-safe slice (OAuth providers + pages) for the proxy
proxy.ts # /backoffice/* admin gate, /pricing + /blog/<slug> 404
# gates (honest wire status when notFound() lands in a
# streamed response), per-request CSP nonce
app/ # App Router: marketing, blog (ISR), signin, settings,
# pricing, billing, backoffice, api/auth route handler
lib/ # api.ts (the three clients + DTO re-exports),
# auth-adapter.ts (14 methods over internalApi()),
# public-session.ts (the session allowlist),
# RSC data helpers, markdown, safe-callback-url
types/api.d.ts # Generated by `yarn generate-types`
REST Surface
A short tour of the contract. Full schema bodies live in the repository-root 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/posts/:slug |
HEAD | none | Existence check, no view-count bump (backs the proxy's 404 gate) |
/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, plus the magic-link rate-limit check (excluded from OpenAPI) |
/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
Three guards live in src/auth/: SessionGuard (Bearer token → req.user), AdminGuard (stacked after it, reads req.user.is_admin), and InternalApiTokenGuard (X-Internal-Token). Behavior and stacking order: Auth Flow.
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 mark the row
status='failed', drop the claim, recordlast_error, and setnext_retry_atto an exponential backoff (30s, 2min, 8min, 32min). Both queues cap retries:email_jobson its per-rowmax_attempts, the deletion queue onUSER_DELETION_MAX_ATTEMPTS(5, inusers.service.ts). The two caps are enforced differently, and the difference matters if you copy one:email_jobscounts in the catch and gates only the retry arm, while the deletion queue counts on the claim and gates both arms. Counting on the claim is what makes the cap hold for an item that kills the process before any catch runs; gating both arms is what stops the stale-claim recovery arm from re-picking it forever.
A deletion that exhausts its cap is abandoned, and what that leaves behind is not always the same half of the job. admin_get_abandoned_deletions on the admin MCP server lists those rows; Billing has the triage and the requeue SQL. The template ships no alerting, so wire the worker's give-up log line into whatever you use.
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 an at-least-once double-process if a worker dies and its claimed row's TTL expires before the work actually completed at SMTP. At-least-once is the explicit contract here.
What Ghost Deletion Erases
POST /users/me/delete-account queues a row, and UserDeletionWorker runs it on the next minute tick. The local half is UsersService.ghostDelete, and every write below happens in one transaction, so an account is never left half-erased.
| Table | What erasure does to it |
|---|---|
users |
Row kept. email becomes deleted- plus the first 16 hex characters of SHA-256 over the user id, at @ghost.local; name becomes Deleted User; image and email_verified are cleared; is_admin goes false. created_at stays, so a deleted account still counts as a signup. |
sessions |
Deleted, so every session the user had stops working at once. This is not a sign-in ban: the original address now matches no row, so signing in with it again runs AuthAdapterService.createUser and gets a brand new account, carrying none of the old one's data. Nothing in the adapter refuses a previously deleted address. |
accounts |
Deleted, along with the OAuth provider tokens they hold. |
verification_tokens |
Deleted, matched on the original address. |
magic_link_attempts |
Deleted. Its primary key is the cleartext normalized email, so it cannot be left to the hourly TTL sweep. |
email_jobs |
Rows kept, to rewritten to the ghost address. Queue stats survive without holding the address. |
items |
Deleted. name and description are free text the user typed, so the demo module's rows go with the account. This is the pattern a feature module copies. |
The worker runs three steps in this order, and the order is load-bearing. First ghostDelete, above. Then BillingService.deleteCustomerData, which returns immediately when billing is dormant or the user has no stripe_customer_id, and otherwise asks Stripe to delete the customer, which is also what cancels a live subscription. Only then does it delete the user_deletion_queue row, fenced on the claim it holds. Clearing the queue row last is what makes a Stripe failure retryable: the row survives the failure, so the next tick claims it and runs both halves again.
The users row is kept on purpose. A real DELETE would take billing_subscriptions with it through that FK's cascade, null out blog_posts.published_by and lose the authorship silently, and fail outright against user_deletion_queue, whose foreign key has no delete rule at all. Ghosting leaves every reference something to resolve to. What survives in that row is id, created_at and updated_at untouched, plus the five overwritten fields above. Whether that residue still counts as personal data where you operate is a judgement the template cannot make for you.
What it does not erase
Every other table in the schema, and every table you add:
blog_posts, whosepublished_byis left pointing at the ghosted author. Posts are admin-authored public content, not the reader's own data, so deleting an author's account unpublishes nothing.billing_subscriptions, which holds plan, status and Stripe ids rather than anything the user wrote. Deletion never writes it. If billing is on and the user has astripe_customer_id, deletion asks Stripe to delete that customer, and thecustomer.subscription.deletedwebhook that follows drops the row toplan=free, status=canceled. With billing dormant, or no customer yet,deleteCustomerDatareturns without calling Stripe and the row stands as it is.billing_webhook_events, from which deletion removes nothing. Where it does reach Stripe, the cancellation webhook that follows adds a row, carrying the deleted user'suser_idand a free-textdetail. The hourly cleanup worker sweeps those at 30 days, so on a billing deploy an account deletion can leave a new user-keyed row behind it for up to a month. Shorten that retention if it is too long for your obligations.billing_disputed_customers, kept on purpose because the person may come back. Billing has the reasoning and the SQL to drop it alongside the account.billing_payment_hook_claims, a Stripe event id and a timestamp with nothing user-linked in it, swept at 30 days alongside the audit rows.- Every table your own modules add.
ghostDeletewrites a fixed list of tables. One it has never heard of is untouched, whatever it holds.
backend/src/users/deletion-policy.spec.ts states this per table and fails on a table that states nothing, so the list above cannot quietly go stale as the schema grows.
An onDelete('CASCADE') on a foreign key to users does not change any of this. Cascades fire on DELETE, and the account-deletion path never deletes the users row, so the cascade is not what cleans up after an erasure request. One route does issue a real DELETE: the NextAuth adapter's deleteUser (DELETE /internal/auth-adapter/users/:id, internal token only), which nothing in the template calls. Declare the cascade anyway, so that route and any hand-run delete stay consistent, but do not read it as a deletion strategy.
So "GDPR account deletion" out of the box means the identifying fields on the account record, everything that authenticates it, and the user's own items. The moment you store personal data in a table of your own, erasing it is a decision you have to make and wire in: Adding a Module has the step and the code.
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. Walkthrough: Adding a Module.
Related
- Home - Docs index
- Auth Flow - Full authentication walkthrough
- Adding a Module - Step-by-step REST + zod walkthrough
- Email - Email service details
- Environment Variables - Configuration reference