next.js→architecture
Live — one mutation, the whole round trip
form submit
Server Action
DAL
Postgres
revalidate
new UI

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.

The mutation round-trip · write ↓ · revalidate + read ↑
00 / 13
STEP 00 · —

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.

Client (ships to browser) Controller · entry point DAL · the model Database Reject path

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:

the recurring pattern an entry point takes the request  →  it authenticates, authorizes, validates  →  it delegates to the DAL, which is the only thing that queries  →  a DTO travels back up.
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.

PieceOne-line roleRuns whereReach for it when…
Server ComponentRender UI on the server with direct backend accessclient + serveryou need to read data or render markup with zero client JS
Route HandlerA raw HTTP endpoint you own end-to-endserverthe caller is external — webhooks, mobile, third parties
Server ActionAn async server function callable from the clientserveryou need to mutate from your own UI
Data Access LayerThe single module that talks to the databaseserver-onlyalways, 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.

The vertical stack
why 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.

Browser · Server · Database
the security punchline Only DTOs cross back into the browser. The DAL scrubs every row down to the fields a caller may see, so a 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.

Which primitive should handle this?
Q1. Who calls it?
Q2. What does it do?
Answer both to see the recommendation
—

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.

authN · authZ · validate → delegate
the trap Because it reads like a private function, it's easy to forget the action is reachable by anyone. Every Server Action needs its own auth, authorization, and input validation — the DAL behind it is not a substitute.

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.

Responsibility mapping
the honest imperfection A Server Component happily fetches and renders — it's both View and Controller. The mapping isn't that Next.js is MVC; it's that keeping reads in the DAL holds the Model boundary even when the View is doing the calling.

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.

lib/db.ts — the client, instantiated once
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;
lib/dal/posts.ts — server-only queries + a DTO
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 } });
}
app/posts/actions.ts — the mutation entry point
'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 };
}
app/posts/page.tsx — the read (Server Component → DAL)
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>
  );
}
app/api/webhooks/stripe/route.ts — the external surface
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 });
}
PropertyWhat the layered boundary buys you
SecuritySecrets and full rows live in server-only code; the DAL scrubs to DTOs and centralizes every authorization check
One seamCaching, logging, an index fix, or an ORM swap happen in one file instead of scattered across every caller
TestabilityMock the DAL once, not the database in every component and action
Small surfaceReads in Server Components create no public endpoints — fewer public doors to defend
conclusion Entry points orchestrate; the DAL executes. Keep them separate and every layer becomes independently testable, swappable, and auditable — which is the whole point of drawing the boundary.

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.

Back to JIT-Atlas