โ† Workshops ยท Part 4 ยท Node.js API

Backend Option A: Node.js API with Route Handlers

One codebase, front and back. Build a Notes API right inside your Next.js app with Route Handlers โ€” the fullstack-JavaScript path. One of three backend options.

๐Ÿ“ Test yourself โ†“
24 min readNode.js APIRead or present

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
PathOwns
app/api/**/route.tsThe endpoints. Export GET/POST/DELETE; parse the request, resolve context, validate, delegate. No business logic, no DB.
lib/db.tsAll data access โ€” the only module that talks to the database, every query scoped by orgId.
lib/auth.tsResolves { userId, orgId } from the verified session โ€” never from the request body.
lib/schemas.tsZod schemas that validate input at the edge and shape output.
middleware.tsA coarse auth gate that runs at the edge before the route (redirect/401 the logged-out).
.env.localSecrets 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.

Step 1

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); }
Step 3

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.

Step 4

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!
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.

Multi-tenant

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.

Beyond request/response

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)
๐Ÿชœ

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.
๐Ÿงฑ

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.
๐Ÿ“Š

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

Check yourself

Quiz โ€” 15 questions

Answer every question, then submit to see your score and the correct answers.

Building something fullstack?

Clean, maintainable fullstack apps are what JunctionNet ships every day. If you’d like help โ€” or a workshop for your team โ€” reach out.

โœ‰๏ธ Get in touch โ† Back to workshops