00 Explorable reference · Next.js 16 · React 19.2
A request dives down the layers,
then rides back up as fresh UI.
One form submission crosses the client boundary, an authenticating and validating Server Action, the server-only Data Access Layer, the ORM, and the database — then revalidatePath turns the trip around and a re-rendered Server Component streams new markup home. Press play, click any node for the framework mechanics, and toggle “what ships to the browser” to watch every server-only layer go dark.
Press play to trace one write
Each step highlights as the request token travels its lane. Warm lanes are client-owned and ship to the browser; cool lanes are server-only and never do.
01 The mental model
You describe the layers. The server enforces them.
Everything below follows from one posture: server-first. A component runs on the server by default; the client is something you opt into. So a query, a secret, and a mutation all have a natural home that never reaches the browser — and the outside world only ever gets in through a small set of entry points, each of which hands the real data work to one place. The pattern repeats everywhere:
Read Server Component → DAL → ORM → DB // no endpoint, no client JS Write Server Action → DAL → ORM → DB // authN + validate, then revalidate HTTP Route Handler → DAL → ORM → DB // the one public, external surface Rule dependencies point DOWN only — the DAL knows nothing about HTTP or rendering
02 The cast & their roles
Four pieces. Knowing who is allowed to do what explains the rest.
Three of these are entry points — ways the outside world gets into your server. The fourth, the DAL, is the seam they all sit on. The crucial asymmetry: entry points face outward and must be treated as public; the DAL faces the database and must never be reachable from a browser.
| Piece | One-line role | Runs where | Reach for it when… |
|---|---|---|---|
| Server Component | Render UI on the server with direct backend access | client + server | you need to read data or render markup with zero client JS |
| Route Handler | A raw HTTP endpoint you own end-to-end | server | the caller is external — webhooks, mobile, third parties |
| Server Action | An async server function callable from the client | server | you need to mutate from your own UI |
| Data Access Layer | The single module that talks to the database | server-only | always, once you have more than a couple of queries |
Mental models in one breath
Server Component — a section that runs on the server and hands the browser finished HTML plus a shrink-wrapped data payload. Route Handler — Request → Response; you own the whole envelope. Server Action — an RPC endpoint disguised as a function call. DAL — the only part of your app allowed to know your database exists.
03 Layers & the boundary
Every layer depends only on the one below it.
This is the whole architecture in one column. The DAL is the load-bearing seam: everything above it is View or Controller; it alone touches the database. Toggle the overlay to dim everything that is server-only and never ships — then click any layer for its role and the framework mechanics.
server-only matters
import 'server-only' does nothing at runtime — it poisons the client build. Pull a DAL function into a 'use client' file and the build fails, so DATABASE_URL and raw rows can never be shipped to a browser by accident.
04 The runtime model · where the code physically runs
Your query never runs in the browser.
The simple “server vs client” story hides a physical topology: three separate places. The browser runs only your Client Components and holds no secrets. The server (Node or Edge) renders Server Components, runs Actions and Handlers, and hosts the DAL and secrets. The database is a different host again, reached over a pooled connection. Click any box for the pieces involved.
passwordHash or stripeCustomerId has no path to the client — the boundary is enforced in code, not by convention.
05 Choosing a primitive
Two questions pick the right entry point.
The overlap between the three primitives dissolves once you ask who calls it and what it does. Whatever the answer, the query itself still belongs in the DAL — only the entry point changes.
Pick who calls it and what it does, and the fork resolves.
Each primitive, and when not to reach for it — click a card for the deep dive:
06 The security gate
A Server Action is a public endpoint wearing a function's clothes.
It looks like a local call, but Next.js compiles it into a POST anyone can invoke. So every action runs the same gate before a single row is written: authenticate, authorize, validate — and only then delegate to the DAL. Any failure returns a value or throws; nothing reaches the database. Click a node to expand the gate.
07 MVC, mapped
Same three responsibilities — a different vocabulary.
Classic MVC splits an app into Model, View, and Controller. The App Router doesn't enforce that split, but the separation of concerns it encodes is exactly what keeps a large app maintainable. Flip the switch to morph the same three boxes between the two naming systems.
08 The whole slice
One vertical Post feature — every layer, once.
This is the animation from step 00 as real, copy-pasteable code. The Server Component reads through the DAL with no endpoint; the Server Action mutates through the DAL after authenticating and validating, then revalidates the read; the Route Handler exposes the one genuinely external surface — a webhook — and takes on the security it doesn't get for free. The DAL is the only file that touches the database, and it hands back DTOs, not rows.
import { PrismaClient } from '@prisma/client'; const g = globalThis as unknown as { prisma?: PrismaClient }; export const db = g.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') g.prisma = db;
import 'server-only'; import { cache } from 'react'; import { db } from '@/lib/db'; // DTO: only the fields any caller is allowed to see. export type PostDTO = { id: string; title: string; createdAt: Date }; export const getPublishedPosts = cache(async (): Promise<PostDTO[]> => { return db.post.findMany({ where: { status: 'published' }, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, createdAt: true }, // scrub the rest }); }); export async function createPost(input: { title: string; content: string; authorId: string }) { return db.post.create({ data: input, select: { id: true } }); }
'use server'; import { z } from 'zod'; import { revalidatePath } from 'next/cache'; import { getCurrentUser } from '@/lib/auth'; import { createPost } from '@/lib/dal/posts'; const NewPost = z.object({ title: z.string().min(1).max(200), content: z.string().min(1) }); export type ActionState = { error?: string } | { ok: true }; export async function addPost(_prev: ActionState, formData: FormData): Promise<ActionState> { const user = await getCurrentUser(); // authN — the action is public if (!user) return { error: 'You must be signed in.' }; const parsed = NewPost.safeParse({ // validate — never trust the client title: formData.get('title'), content: formData.get('content'), }); if (!parsed.success) return { error: 'Invalid input.' }; await createPost({ ...parsed.data, authorId: user.id }); // delegate to the DAL revalidatePath('/posts'); // refresh the cached read return { ok: true }; }
import { getPublishedPosts } from '@/lib/dal/posts'; import { NewPostForm } from './new-post-form'; export default async function PostsPage() { const posts = await getPublishedPosts(); return ( <main> <NewPostForm /> <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> </main> ); }
import { markPaid } from '@/lib/dal/posts'; export async function POST(request: Request) { const sig = request.headers.get('stripe-signature'); const raw = await request.text(); // verifyStripeSignature(raw, sig) ← Route Handlers have NO built-in CSRF/auth; you verify. const event = JSON.parse(raw); if (event.type === 'checkout.session.completed') await markPaid(event.data.object.metadata.postId); return Response.json({ received: true }); }
| Property | What the layered boundary buys you |
|---|---|
| Security | Secrets and full rows live in server-only code; the DAL scrubs to DTOs and centralizes every authorization check |
| One seam | Caching, logging, an index fix, or an ORM swap happen in one file instead of scattered across every caller |
| Testability | Mock the DAL once, not the database in every component and action |
| Small surface | Reads in Server Components create no public endpoints — fewer public doors to defend |
Part 1 of 2 — the canonical reference. The machinery underneath (the RSC wire format, Server Action internals, the full caching model, the DAL as a security boundary, and the wider architecture family — Hexagonal, Clean, Onion, CQRS, DDD) lives in Part 2.