What you'll build
A backend in the same project as your frontend from Part 1 โ a small Notes API (list, create, delete) using Next.js Route Handlers. One codebase, one deploy: the fullstack-JS path.
A file at app/api/notes/route.ts becomes an API endpoint. Your frontend calls it with fetch; it reads and writes your data. No separate server.
This is backend Option A. Prefer a standalone service? See Part 5 ยท Python + SAM or Part 6 ยท Docker + ECS Fargate.
The idea: Route Handlers are your backend
Any folder under app/ with a
route.ts (instead of page.tsx)
is a backend endpoint. Export functions named after HTTP methods โ
GET, POST,
DELETE โ that run on the server.
The project structure
Start with the minimum, then let it grow into the shape below โ one concern per file in lib/, and routes that stay thin:
my-app/
โโ app/
โ โโ notes/page.tsx # the UI โ /notes
โ โโ api/
โ โโ notes/route.ts # GET + POST โ /api/notes
โ โโ notes/[id]/route.ts # DELETE โ /api/notes/:id
โ โโ webhooks/stripe/route.ts # inbound webhook (public, signed)
โโ lib/
โ โโ db.ts # data layer โ the ONLY place that touches the DB
โ โโ auth.ts # resolve { userId, orgId } from the session
โ โโ schemas.ts # Zod request/response validation
โโ middleware.ts # coarse "are you logged in?" gate at the edge
โโ .env.local # secrets (gitignored) โ never commit
| Path | Owns |
|---|---|
app/api/**/route.ts | The endpoints. Export GET/POST/DELETE; parse the request, resolve context, validate, delegate. No business logic, no DB. |
lib/db.ts | All data access โ the only module that talks to the database, every query scoped by orgId. |
lib/auth.ts | Resolves { userId, orgId } from the verified session โ never from the request body. |
lib/schemas.ts | Zod schemas that validate input at the edge and shape output. |
middleware.ts | A coarse auth gate that runs at the edge before the route (redirect/401 the logged-out). |
.env.local | Secrets for local dev โ gitignored. The source of truth in prod is your platform's env store (Vercel). |
Grow by adding a file, not by fattening a route. A new resource is a new app/api/<name>/route.ts plus a few functions in lib/db.ts โ the existing routes don't change.
Your first Route Handler
// app/api/notes/route.ts
import { NextResponse } from 'next/server';
import { listNotes, createNote } from '@/lib/db';
export async function GET() {
return NextResponse.json(await listNotes());
}
export async function POST(req: Request) {
const { text } = await req.json();
if (!text) return NextResponse.json({ error: 'text required' }, { status: 400 });
return NextResponse.json(await createNote(text), { status: 201 });
}
Step 2
A data layer
Keep data access in one module so handlers stay clean. Start in-memory, then swap in a real DB:
// lib/db.ts โ start simple (resets on restart)
type Note = { id: string; text: string };
let notes: Note[] = [];
export async function listNotes() { return notes; }
export async function createNote(text: string) {
const note = { id: crypto.randomUUID(), text };
notes.push(note); return note;
}
export async function deleteNote(id: string) { notes = notes.filter((n) => n.id !== id); }
Make it persistent: replace the array with a real database โ Part 3 covers the options (Supabase Postgres is quickest). Then only lib/db.ts changes.
Call it from the client
const notes = await (await fetch('/api/notes')).json();
await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'Buy milk' }),
});
Even simpler: a Server Component can call listNotes() directly, and a Server Action can handle a form submit without a route. Route Handlers shine when something outside your app needs the endpoint.
Dynamic routes for delete
// app/api/notes/[id]/route.ts
import { NextResponse } from 'next/server';
import { deleteNote } from '@/lib/db';
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteNote(id);
return new NextResponse(null, { status: 204 });
}
Step 5
Validation & errors
Never trust the request body. Validate (a library like Zod helps) and return a clear status code:
try {
const data = schema.parse(await req.json()); // throws on bad input
} catch {
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
}
The database connection
The in-memory array is fine for a demo; a real app needs a database โ and serverless Next.js hits the same trap AWS Lambda does: every serverless invocation can open its own connection, and Postgres has a hard limit. Two rules keep you safe โ reuse one client, and go through a pooler.
Reuse one client โ don't reconnect per request
Cache the client on globalThis so dev hot-reloads and warm invocations reuse a single instance instead of leaking a new connection every time:
// lib/db.ts โ one pooled client, cached across reloads/invocations
import { Pool } from 'pg';
const g = globalThis as unknown as { pool?: Pool };
export const pool =
g.pool ?? new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
if (process.env.NODE_ENV !== 'production') g.pool = pool; // survive HMR in dev
export async function listNotes(orgId: string) {
const { rows } = await pool.query(
'select id, text from notes where org_id = $1', [orgId]);
return rows;
}
Mind the connection storm. On Vercel each concurrent request can be its own instance โ thousands of tiny pools exhaust Postgres. Keep max small and connect through a pooler: Supabase's pooler (port 6543), Neon, PgBouncer, or a serverless driver (@vercel/postgres, Prisma Accelerate, Neon HTTP). The pooler multiplexes many callers onto a few real connections. An ORM (Prisma, Drizzle) sits on top of the same pool.
Two URLs, two jobs. App traffic uses the pooled connection string; schema migrations use a direct one. Run migrations in CI before the deploy so new code never meets an old schema.
Secrets & environment variables
Your DATABASE_URL and API keys are secrets. In Next.js the
make-or-break rule is the server/client boundary: anything that
reaches a component in the browser is public.
# .env.local (gitignored โ never commit)
DATABASE_URL=postgres://โฆ # server-only: stays on the server
STRIPE_SECRET_KEY=sk_live_โฆ # server-only
NEXT_PUBLIC_SITE_URL=https://โฆ # NEXT_PUBLIC_ = shipped to the browser!
process.env.Xis server-only by default โ read it in Route Handlers, Server Components, andlib/. It never enters the client bundle.NEXT_PUBLIC_is inlined into the browser bundle โ only for genuinely public values (a site URL, a publishable key). Put a secret behind that prefix and you've shipped it to every visitor.- Source of truth is your platform โ set the same vars in Vercel (Project โ Settings โ Environment Variables), scoped per environment: Development, Preview, Production.
vercel env pull .env.local # pull the project's env into your local file
vercel env add DATABASE_URL production # add / rotate a secret in the platform
Never commit .env.local, and never prefix a secret with NEXT_PUBLIC_. Deploying from GitHub Actions instead of Vercel's Git integration? Store the same values in GitHub Secrets and pass them to the build โ the principle is identical: secrets live in the platform, never the repo. Rotate a leaked key immediately.
The request's user & organization context
A real API is multi-tenant: every request belongs to a signed-in
user in an organization, and one org must never see
another's data. Don't read the identity from the body or a query param โ
resolve it from the session on the server, and scope every query by
orgId.
// lib/auth.ts โ resolve identity from the session cookie (server-side)
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
export async function requireContext() {
const supabase = createServerClient(url, key, { cookies: cookies() });
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Response('Unauthorized', { status: 401 });
return { userId: user.id, orgId: user.app_metadata.org_id as string };
}
// app/api/notes/route.ts โ resolve context, then scope the query
export async function GET() {
const { orgId } = await requireContext();
return NextResponse.json(await listNotes(orgId)); // lib/db filters by org_id
}
Never trust identity from the client. A request body can claim any orgId; only the verified session is real. Resolve it server-side and scope in lib/db.ts, so no route can leak another tenant's data. Use middleware.ts for the coarse "logged in?" gate; do the precise org check in the handler / data layer.
Background work & webhooks
A Route Handler's job is to answer the request. Slow follow-up work โ sending an email, reindexing, calling a third party โ shouldn't make the caller wait, and events arriving from other systems need a public endpoint.
Do the slow work after the response
Next.js after() runs a callback after the response is sent โ fire-and-forget for non-critical tasks, off the caller's critical path:
// app/api/notes/route.ts
import { after } from 'next/server';
export async function POST(req: Request) {
const { orgId } = await requireContext();
const note = await createNote(orgId, (await req.json()).text); // commit first
after(() => sendDigestEmail(orgId)); // runs AFTER the 201 is returned
return NextResponse.json(note, { status: 201 });
}
Need durability, retries, or fan-out? after() is best-effort and bounded by the function's lifetime. For work that must not be lost, hand it to a queue โ Upstash QStash, Inngest, or Vercel Queues enqueue a job and a separate function processes it with retries and a dead-letter. Same idea as an event bus (Part 7): publish a fact, let a separate worker react.
Receive webhooks
An inbound webhook (Stripe, Resend, GitHub) is just a POST route โ but it's called by a machine with no session cookie, so it's public and signature-verified, not session-authed:
// app/api/webhooks/stripe/route.ts โ public route; verify the signature
export async function POST(req: Request) {
const body = await req.text(); // RAW body for the signature
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new NextResponse('bad signature', { status: 400 });
}
after(() => handle(event)); // 200 fast; do the work off the path
return NextResponse.json({ received: true });
}
A webhook route skips your session auth โ the sender has no cookie โ so it must verify the provider's signature on the raw body, and be idempotent (providers retry, so the same event can arrive twice). Never act on an unverified webhook.
A CLAUDE.md for a fullstack app
Pin down the API conventions so new endpoints stay consistent:
# CLAUDE.md
## What this is
Fullstack Next.js app (App Router). UI in app/, backend in app/api/** (Route Handlers).
## Structure # one concern per file in lib/; routes stay thin
app/
notes/page.tsx # UI
api/notes/route.ts # GET + POST
api/notes/[id]/route.ts # DELETE
api/webhooks/*/route.ts # public, signature-verified
lib/
db.ts # data access โ the ONLY place that touches the DB
auth.ts # resolve { userId, orgId } from the session
schemas.ts # Zod request/response validation
middleware.ts # coarse auth gate ยท .env.local # secrets (gitignored)
## Commands
- Dev / Build / Lint: npm run dev / build / lint
## Backend conventions
- Endpoints: app/api/<name>/route.ts (export GET/POST/DELETE). Dynamic: [id]/route.ts.
- Routes are thin: parse โ resolve context โ validate โ delegate to lib/. No DB in routes.
- ALL data access goes through lib/db.ts. Validate every body with Zod โ proper status codes.
## Auth & multi-tenancy
- Resolve { userId, orgId } from the SESSION (lib/auth.ts), never from the body/query.
- Scope every query by orgId in lib/db.ts โ no route may leak another tenant's data.
## Database
- One pooled client cached on globalThis; small max; connect via a pooler (Supabase/Neon/PgBouncer).
- Migrations run in CI before the deploy.
## Secrets & env
- Secrets live in .env.local (gitignored) + the platform env (Vercel), per environment. Never commit.
- process.env.X is server-only; NEXT_PUBLIC_ ships to the browser โ never a secret.
## Background & webhooks
- Slow work: after() (best-effort) or a queue (QStash/Inngest) for durable, retried jobs.
- Webhooks: public route, verify the provider signature on the raw body, be idempotent.
## Verify
npm run lint, then curl the endpoints (or drive the UI) before saying done.
Let Claude Code build the endpoints
> Add a Notes feature: a lib/db.ts data layer, a GET+POST route
handler at app/api/notes, a DELETE at app/api/notes/[id], and a
/notes page that lists notes with a form to add and a delete
button. Validate input and handle errors.
Grow
Adding the next resource โ same shape
The point of the layering is that the second resource, and the twentieth, is added the same way as the first. To add orders next to notes you touch the same layers โ and nothing existing changes:
// new files only โ notes/* is untouched
app/api/orders/route.ts // endpoints โ thin; reuse requireContext()
app/api/orders/[id]/route.ts // dynamic route
lib/orders.ts // data access for orders โ org-scoped queries
lib/schemas.ts // + OrderCreate / OrderResponse (Zod)
- Endpoints only under
app/api/<name>/route.tsโ thin: parse, resolve context, validate, delegate. No logic in the route. - Data access in a
lib/module โ never in the route; every query scoped byorgId. - One Zod schema per input/output, and reuse the shared session + DB helpers โ don't re-auth or reconnect.
- Nothing existing changes โ a new resource is new files, not edits to
notes.
When to split it out. Keep resources as modules in the one codebase for as long as you can โ it's the cheapest place to work. Promote a feature to a standalone service (Option B or C) only when it needs to scale or deploy independently, has a different runtime, or a different team owns it. The layered shape travels with it.
Ask for a feature the way an engineer would
Once the API exists, don't just say "add search." The best prompts state the contract, respect your layers, pin down validation and status codes, protect what already works, and end with a way to verify:
> Add tag filtering to the Notes API.
Contract: GET /api/notes?tag=work returns only notes with that
tag; no tag param returns all notes.
Keep the layers separate โ put the query logic in lib/db.ts as
getNotes({ tag }); the route handler only parses the query param
and calls it.
Validate: tag must be a non-empty string when present โ return
400 on bad input, 200 with a JSON array otherwise.
Don't change the existing GET/POST behavior or response shape.
Then give me a curl command for each case so I can verify.
- Contract first โ exact route, query params, and response shape leave no room to guess.
- Name the layers โ "query logic in
lib/db.ts, handler stays thin" keeps the code clean instead of piling logic into the route. - Validation & status codes โ say what's valid and which code each case returns (
400vs200). - Guard existing behavior โ "don't change GET/POST" stops a new feature from quietly breaking an old one.
- End with verification โ ask for
curlcommands (or a test) so you confirm it, not just the model.
Reusable shape: goal โ contract โ where the code goes โ validation & errors โ don't break X โ how I'll verify. It works for any endpoint โ pagination, auth, a new resource.
Say how the frontend should show the data
An endpoint that returns JSON is only half the job โ tell Claude Code how the data should look on screen. Name the views you want and point them at the API you already have:
> Add a /notes/dashboard page that visualizes the Notes data:
- Three stat cards on top: total notes, number of tags, and
notes added this week.
- A bar chart of notes-per-tag (use Recharts).
- A sortable table of the most recent notes below.
Fetch from the existing GET /api/notes. If you need an aggregate
(counts per tag), add GET /api/notes/stats and keep the counting
logic in lib/db.ts โ don't compute it in the component.
Match the app's dark theme and make it work on a phone.
Show placeholders while it loads and an empty state at zero notes.
- Name the views โ "stat cards, a bar chart, a sortable table" is far clearer than "show the data nicely."
- Pick the chart library โ say
Recharts(or Tremor) so you don't get a random one. - Reuse the API, or add a clean aggregate โ let the server do the counting in
lib/db.ts; keep the component about display, not math. - Ask for the states โ loading placeholders and an empty state are what separates a demo from a real UI.
- Theme & responsive โ "match the dark theme, work on a phone" stops it from looking bolted on.
Split the work: ask for the endpoint first (Contract โ layers โ verify), confirm it with curl, then ask for the view that consumes it. One change at a time is easier to check than a full-stack feature in one shot.
Deploy
Route Handlers deploy with the rest of your Next.js app โ no extra step. Follow Part 3 (Vercel is one command). Move off the in-memory array to a real DB first, or your notes vanish on restart.
The other backend paths: Part 5 ยท Python + SAM (standalone serverless functions) and Part 6 ยท Docker + ECS Fargate (a long-running container). Same Notes API, different stacks โ pick whichever fits.