Module 2

Backend with Next.js + PostgreSQL

Build real APIs inside your Next.js app. You'll start with your first route handler, then connect to PostgreSQL and build CRUD, add validation and authentication, and finish with transactions, security and production architecture.

Route Handlers Server Actions pg + Prisma Auth & security
Level 1 — BasicsWhat a backend does, route handlers, requests & responses, and connecting to PostgreSQL

What is a backend? Basic

The frontend is what users see. The backend is the part they don't see. It receives requests, applies business rules, talks to the database and sends back responses.

Restaurant analogyThe customer (browser) orders from the waiter (API). The waiter takes the order to the kitchen (backend logic), which takes ingredients from the storeroom (PostgreSQL). The customer never walks into the kitchen.
Full-stack architecture in one Next.js app
BrowserReact UI
fetch / form
Next.js serverRoute Handlers · Server Actions · Server Components
SQL queries
PostgreSQLtables, rows

Your frontend and backend live in the same project and are deployed together.

Three ways to run server code in Next.js

Server Components

Read data directly while rendering a page. Best for GET-style reads.

Server Actions

Functions marked "use server". Best for mutations from your own forms.

Route Handlers

app/api/**/route.ts. A real REST API for mobile apps, webhooks and third parties.

HTTP & REST basics Basic

Every API call is an HTTP request made of a method, a URL, headers and an optional body. The server answers with a status code, headers and a body (usually JSON).

MethodMeaningExample
GETReadGET /api/products
POSTCreatePOST /api/products
PUT/PATCHUpdate (full/partial)PATCH /api/products/7
DELETEDeleteDELETE /api/products/7
StatusMeaning
200 / 201OK / Created
204No content (deleted)
400 / 422Bad input / validation failed
401 / 403Not logged in / not allowed
404 / 409Not found / conflict (duplicate)
500Server bug

Route Handlers (API routes) Basic

Create a route.ts file inside app/ and export functions named after HTTP methods.

URL → file → function
GET /api/hello
maps to
app/api/hello/route.ts
calls
export function GET()
Don't mix them upYou can't put route.ts and page.tsx in the same folder. Keep APIs under app/api/.

Reading requests & sending responses Basic

Anatomy of a request
Method + URLPATCH /api/products/7?notify=true
Dynamic params{ id: "7" } ← from the [id] folder
Search paramsnotify=true
Headers + cookiesAuthorization, Content-Type, session cookie
Body{ "price": 999 }
NeedCode
JSON bodyawait req.json()
Form bodyawait req.formData()
Query stringreq.nextUrl.searchParams.get("q")
JSON response + statusNextResponse.json(data, { status: 201 })
Empty responsenew Response(null, { status: 204 })
RedirectNextResponse.redirect(new URL("/login", req.url))

Connecting to PostgreSQL Basic

We'll use pg (node-postgres), the standard PostgreSQL driver for Node.js. If you haven't set up a database yet, start with the PostgreSQL notes.

  1. Install the drivernpm install pg and npm install -D @types/pg
  2. Add the connection stringPut it in .env.local.
  3. Create one shared connection poolCreate it once in lib/db.ts and import it everywhere.
Why a connection pool?
Many requests
borrow
Poole.g. 10 open connections, reused
queries
PostgreSQL

Opening a new connection for every query is slow, and Postgres limits how many connections it accepts. A pool keeps a few open and shares them.

Server-onlyOnly import lib/db.ts from server code: Route Handlers, Server Actions and Server Components. Add import "server-only"; at the top, and the build will fail if a Client Component imports it by mistake.
Level 2 — IntermediateCRUD API, SQL injection, validation, Server Actions, ORMs and error handling

Build a full CRUD API Intermediate

CRUD = Create, Read, Update, Delete. Here's a complete products API.

EndpointFileSQL
GET /api/productsapi/products/route.tsSELECT
POST /api/productsapi/products/route.tsINSERT … RETURNING *
GET /api/products/:idapi/products/[id]/route.tsSELECT … WHERE id=$1
PATCH /api/products/:idapi/products/[id]/route.tsUPDATE … RETURNING *
DELETE /api/products/:idapi/products/[id]/route.tsDELETE
NUMERIC comes back as a stringpg returns NUMERIC/DECIMAL values as strings so they don't lose precision. Convert them with Number(row.price) only when that's safe, or use a decimal library for money.

SQL injection — the #1 mistake Intermediate

If you glue user input into a SQL string, an attacker can type SQL that changes your query.

What the attacker types
Input'; DROP TABLE users; --
string concat
Your query becomesSELECT … WHERE name = ''; DROP TABLE users; --'
runs
Table deleted
Vulnerable
Safe — parameterized
RuleValues always go in $1, $2… placeholders. You can't parameterize identifiers such as table names, column names or ORDER BY direction. For those, check the input against an allow-list: ["name","price"].includes(sort).

Validating input with Zod Intermediate

Never trust the client. Zod checks the shape of the data at runtime and gives you TypeScript types from the same schema.

Validation gate
Raw bodyunknown
safeParse
Zod schema
valid ✓
Typed data → DB

If validation fails, return 400 / 422 with the error list. Bad data never reaches the database.

Server Actions with the database Intermediate

When the only caller is your own UI, a Server Action is usually simpler than an API route. You don't need to write a fetch or define a URL.

Use…When
Server ComponentReading data to show on a page
Server ActionForms and mutations triggered from your own Next.js UI
Route HandlerPublic/external API, mobile apps, webhooks (Stripe, GitHub), file downloads, client-side fetching with SWR

Using an ORM (Prisma) Intermediate

An ORM (Object-Relational Mapper) lets you query the database with typed JavaScript methods instead of raw SQL strings. It also manages migrations, which are version-controlled changes to your tables.

Raw SQL (pg)
  • Full control, fastest
  • You learn real SQL
  • Manual types & migrations
VS
ORM (Prisma / Drizzle)
  • Auto-complete and type safety
  • Migrations built in
  • Complex queries can be awkward
Versions changeNewer Prisma releases (v7+) moved to a prisma.config.ts file and driver adapters (@prisma/adapter-pg). The schema.prisma models and the query API shown above stay the same. Follow the setup guide for the version you install. Drizzle ORM is a popular SQL-like alternative.

Error handling Intermediate

Return consistent JSON errors, map known database errors to the right status codes, and never show stack traces to users.

PG codeNameHTTP
23505unique_violation409 Conflict
23503foreign_key_violation400 / 409
23502not_null_violation400
22P02invalid_text_representation (e.g. "abc" as INT)400
Level 3 — AdvancedAuthentication, route protection, transactions, pagination, performance and architecture

Authentication (hash + JWT cookie) Advanced

Concert wristbandAt the gate (login) you show your ID (password) once and get a signed wristband (a JWT in a cookie). After that, the guards (server) only check the wristband. They don't ask for your ID again.
Login flow
email + password
POST /login
Find userSELECT by email
bcrypt.compare
Sign JWT{ userId, role }
Set-Cookie
httpOnly cookiesent on every request
Production tipLibraries like Auth.js (NextAuth), Better Auth or Clerk handle OAuth (Google/GitHub), email links and session rotation for you. Learn the manual way first so you understand what they do.

Protecting routes: proxy / middleware Advanced

A proxy (called middleware before Next.js 16) runs before a request reaches your page or API. Use it for quick checks such as redirecting logged-out users.

Defense in depth
Request /dashboard
1st check
proxy.tshas session cookie?
2nd check
Page / Action / APIgetSession() + role
ok
Data

Proxy is an optimistic first gate. Always check auth again where the data is read or changed.

Transactions Advanced

A transaction groups several queries so that all succeed or none do. A classic example is placing an order: create the order, reduce stock and charge the wallet. If any step fails, everything is undone.

All-or-nothing
BEGIN
INSERT order
UPDATE stock
all ok?
COMMITsaved
ROLLBACKon any error

Pagination, sorting & filtering Advanced

Offset pagination
  • LIMIT 20 OFFSET 40
  • Easy and supports "page 3 of 10"
  • Gets slow on deep pages, and rows can shift between requests
VS
Cursor (keyset) pagination
  • WHERE id < $last ORDER BY id DESC LIMIT 20
  • Fast at any depth because it uses the index
  • Good for infinite scroll; no "jump to page"

Performance & scaling Advanced

Avoid N+1 queries

Don't run one query per item inside a loop. Use one JOIN or WHERE id = ANY($1) instead.

Add indexes

Index the columns you filter or join on. Check with EXPLAIN ANALYZE (see the PostgreSQL page).

Pooling in serverless

Serverless functions can open too many connections. Use PgBouncer or a pooled URL (Neon, Supabase).

Cache reads

Cache data that rarely changes with Next.js caching (revalidate / "use cache") or Redis.

Select only needed columns

Avoid SELECT * on wide tables in hot paths.

Background jobs

Send emails and process images in a queue so the request isn't blocked.

N+1 (101 queries)
One JOIN (1 query)

Security checklist Advanced

  • Parameterized queries everywhere
  • Validate every input (Zod)
  • Hash passwords with bcrypt or argon2, never store them in plain text
  • Put sessions in httpOnly + secure + sameSite cookies
  • Check auth and ownership: WHERE id=$1 AND user_id=$2
  • Rate-limit login and public endpoints
  • Keep secrets in env vars, never in NEXT_PUBLIC_
  • Use a DB user with minimum privileges (not superuser)
  • Return generic error messages and log the details server-side
  • Verify webhook signatures (Stripe and others)
IDOR (Insecure Direct Object Reference)Being logged in isn't enough. If user A requests /api/orders/55, check that order 55 belongs to A: SELECT * FROM orders WHERE id=$1 AND user_id=$2.

Production project architecture Advanced

As your app grows, split the code into layers, so route files stay thin and the business logic can be tested.

Layered backend
Transport layerroute.ts / actions.ts: parse the request, validate, call the service, format the response
Service layerservices/order.service.ts: business rules (stock check, pricing, emails)
Data / repository layerdb/order.repo.ts: the only place that writes SQL
PostgreSQLtables, constraints, indexes
Suggested folders
  • src/
    • app/ pages + api routes + actions (thin)
    • server/
      • services/ business logic
      • repositories/ SQL queries
      • db.ts pool
      • auth.ts
    • lib/validators/ zod schemas (shared with the frontend)
    • components/
  • migrations/ numbered SQL files or Prisma migrations
Practice & ReviewInterview questions, exercises and a cheat sheet

Interview questions

What is the correct way to stop SQL injection?

A Server Action is an RPC-style function for mutations from your own React UI. It works with forms, progressive enhancement and revalidatePath. A Route Handler is an HTTP endpoint with a URL, for external clients, webhooks and client-side fetch.

Opening a PostgreSQL connection is expensive (TCP + auth + process fork), and the server allows only a limited number. A pool reuses a few open connections across many requests.

JavaScript can't read an httpOnly cookie, so an XSS attack can't steal the token. Add sameSite and secure to reduce CSRF risk and keep the cookie on HTTPS only.

It locks the selected rows until the transaction ends. Other transactions that try to update or lock those rows must wait, which prevents race conditions such as overselling stock.

Practice projects

  1. Notes APICRUD with pg, Zod validation and consistent errors.
  2. Auth systemRegister, login and logout with bcrypt + JWT cookie, plus a protected /dashboard using proxy.
  3. Mini e-commerceProducts, cart and orders, using a transaction to place each order. Add pagination and filters.

Cheat sheet

Route Handler
  • export async function GET()method
  • await req.json()body
  • req.nextUrl.searchParamsquery
  • await params[id]
  • NextResponse.json(d,{status})reply
pg
  • new Pool({connectionString})pool
  • db.query(sql, [v])query
  • RETURNING *get row back
  • db.connect()tx client
  • client.release()return it
Auth
  • bcrypt.hash(pw, 12)hash
  • new SignJWT().sign()token
  • jwtVerify(t, secret)verify
  • (await cookies()).set()cookie
Actions
  • "use server"mark file
  • revalidatePath("/x")refresh
  • redirect("/x")navigate
  • action.bind(null, id)pass args
Backend notes