00 An explorable comparison · Next.js App Router · TanStack Query
Render. Endpoint. Action.
Three entrypoints, one policy layer.
Route Handlers and Server Actions both run on the server — so why do they behave so differently? Because App Router data access isn't two features, it's three different server entrypoints: a Server Component render says "I need data to render this route", a Route Handler says "I need an HTTP endpoint", and a Server Action says "I need a React interaction to run server code." Same server, different doorways — and behind all three sits one server-only policy layer that decides who may actually touch the data and what shape may leave. This guide has two axes: the doorways are about contract — who may call, what crosses the wire, who repaints; the DAL is about policy — auth, ownership, DTOs, transactions. Knock on any door below, then click any box to go deeper.
01 The frame · two orthogonal axes
Every feature is a point on two axes, not one.
The mistake that makes App Router data access feel chaotic is collapsing two independent questions into one. Contract is the horizontal axis — which doorway: who is allowed to call it, what crosses the wire, who repaints afterward. Policy is the vertical axis — what the Data Access Layer enforces once you're inside: is there an actor, may they touch this row, which fields may leave, what must be atomic, what may be cached. They're orthogonal: every doorway still needs every policy concern. A Server Action isn't "safe" because it's not a public URL, and a Route Handler isn't automatically authorized because it has a nice 401 to return. Pick a cell — or flip to the layered view — to see how the two axes meet.
Contract decides the door; policy decides what happens once you're through it. This guide walks the horizontal axis first (Part II · the entrypoints), then the vertical axis (Part III · the policy layer), then puts them back together (Part IV).
02 First-principles difference
A Route Handler is URL-first. A Server Action is function-first.
A Route Handler is an explicit HTTP resource. It lives in app/**/route.ts, exports functions named after HTTP methods — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS — and speaks the Web Request/Response APIs. The URL is the API surface: anyone who can send an HTTP request to that route can call it, assuming your auth allows it.
A Server Action is not primarily a URL. It's a server function marked 'use server' that React/Next can invoke from a component interaction — <form action>, <button formAction>, or a client transition. The React interaction is the contract; the network plumbing underneath is framework-managed.
export async function POST(request) — the protocol is mine. A Server Action says 'use server' — the protocol is React's.
03 What actually crosses the network
Same server. Very different bytes.
With a Route Handler you own the protocol — the URL, method, status code, headers, body shape, error format, cache headers and CORS behavior are yours to design. That's what makes it portable: a browser, a mobile app, a webhook provider, a curl command or another backend can all call the same endpoint.
With a Server Action you gain deep React/Next integration but lose a clean public HTTP contract. What crosses the wire is a framework-managed POST carrying a compiled action ID plus serialized arguments — and what comes back can include a fresh RSC Payload for the current route. Callable over the network, yes; a stable public API, no. Pick a doorway and replay its traffic.
04 Compilation difference
The browser never receives your database function.
A Route Handler is discovered by the filesystem router: app/api/orders/route.ts becomes the registered entrypoint /api/orders, and each exported method becomes a handler. Request in → your function → Response out. No React reference involved.
Server Actions are compiler-recognized. At build time, the compiler splits a 'use server' module in two: the server bundle keeps the real implementation, while the client bundle gets a reference — an action ID plus a dispatcher that knows how to POST back to Next. That's the "magic": you can import { updateUser } in a Client Component and call it like a local function, but what the browser actually got is a callable stub. Run the build below and watch the module split.
app/api/orders/route.ts
│ Next route discovery
▼
registered HTTP entrypoint: /api/orders
▼
exported methods become handlers:
GET · POST · PUT · DELETE
// the route file owns the lifecycle:
// Request in → your function → Response out'use server' module
│ next build
▼
server bundle: actual implementation
client bundle: action ID + dispatcher wrapper
▼
runtime: dispatcher POSTs the ID +
serialized args back to Next.js
// callable like a function, reachable like a URL05 Response difference · the mutation loop
One mutation. Two roundtrips vs one.
A Route Handler returns a normal HTTP response — status, headers, JSON — and then the caller decides what happens next: update local state? refetch a query? redirect? invalidate the TanStack Query cache? A Server Action can return a value and trigger a server re-render, sending a fresh RSC Payload back in the same response. Step through both mutation loops side by side and watch the roundtrip counters — this single difference is why Server Actions feel so "integrated" with the RSC tree, and why the TanStack loop trades an extra request for full client-side control.
06 Caching & revalidation
Who marks it stale, and who sees it fresh.
This is where most confusion lives. All three doorways can call revalidatePath — but only a Server Action gets to ship the fresh UI back in the same response. A Route Handler that revalidates only marks the cache stale for the next visit; if you called it from a Client Component and want the current page to visibly update, you still need router.refresh() or queryClient.invalidateQueries() — TanStack's way of marking matching cached queries stale and refetching the active ones.
the internal-read trap
The default App Router read is a Server Component awaiting data directly — fetch, an ORM, or a database. Calling your own Route Handler from your own Server Component adds an HTTP hop inside your own server for nothing.
Server Component
│ fetch('https://my-app.com/api/products')
▼
Route Handler ← extra hop, extra serialization
▼
DBServer Component
▼
server-only data function // import 'server-only'
▼
DB07 Security tradeoffs
Both are server entrypoints. Neither is automatically safe.
Route Handler security is familiar HTTP API security: validate the method, the session, the authorization, the body, the ownership; rate-limit; check CORS if browser-facing; return a safe response. Its big advantage is explicit HTTP failure modes — 401 403 404 422 429 500 — exactly what APIs and integrations want.
Server Actions feel like "just functions", and that feeling is the trap.
"This button is only rendered for admins, so the action is safe." // the UI is a rendering decision, // not a security boundary
"The action is a POST-reachable server
entrypoint. The UI is not my security
boundary. The action itself must check
auth and authorization."Prove it to yourself. The console below hosts a deleteOrder entrypoint — flip it between a Route Handler and a Server Action, toggle whether the code checks auth and ownership, then attack it from outside the UI.
server log
page.tsx, route.ts and actions.ts each re-implement the same session-and-ownership dance, one of them will eventually forget the ownerId clause. Part III pushes those checks down into the policy layer so they live in exactly one place. We've been walking the horizontal axis; now we turn ninety degrees.
08 Part III · the policy layer · why it exists
Without a layer, every file becomes a mini-backend.
Because a Server Component can await db.user.findMany() directly, every component, action and handler can become its own little backend — and that's exactly the problem. The security model scatters: each caller must independently remember to authenticate, authorize, scope by owner, select safe columns, avoid leaking secrets, handle errors, and shape a DTO. The bigger the app, the likelier one file forgets where: { ownerId: actor.id } or returns a raw row carrying passwordHash. Toggle the switch and watch the same six responsibilities either scatter across the tree or collapse into one shared layer.
The DAL isn't a Next.js feature, a required folder, or an ORM wrapper. It's an architectural boundary: the server-only layer that answers one question — who is allowed to touch the real data source, and what safe shape may leave? Next.js's own data-security guide recommends a dedicated DAL for new projects: server-only, authorization-checking, returning minimal DTOs.
09 Two boundaries · module vs data
server-only protects modules. DTOs protect data.
A DAL guards two different boundaries that are easy to conflate. The module boundary keeps server code out of the client bundle: import 'server-only' is a build-time poison pill. The data boundary keeps sensitive values out of the HTML, the RSC Payload, action return values and Client Component props. The first is enforced by the compiler; the second only by discipline — explicit selects, explicit return types, and field-level authorization in the mapper.
how server-only actually works
It's not runtime magic and it doesn't "move" code anywhere. The package ships an export map that resolves to a throwing module in any client/browser compilation, so a client import fails the build:
✓ client imports this module → build fails ✗ does NOT move code to the server ✗ does NOT authenticate anyone ✗ does NOT filter a single column // it only says: "never bundle me for the browser"
the leak inspector
The RSC Payload is not a private vault — anything a Server Component passes to a Client Component is serialized into it and visible in the network tab. Pass a raw row through and watch what a curious user finds. Flip to the DTO path to seal it.
the DAL returns
what a user sees in DevTools → Network → the RSC payload
server-only stops module leakage → 5 · taint APIs catch accidental raw-object passing → 6 · review & tests. Taint is a rail, not a replacement: cloning or deriving from a tainted object produces a fresh, un-tainted value.
10 Auth belongs next to the data
Not in the middleware. In the query.
Every entrypoint can be reached differently: a hidden button still accepts a POST, a page-level admin check doesn't protect an action defined elsewhere, and a proxy that checks auth doesn't authorize a specific row. CVE-2025-29927 made this concrete — affected Next.js versions could bypass authorization that lived in middleware. The lesson isn't "middleware is bad"; it's that middleware is a coarse gate and the DAL is the last line. Run the attack replay with auth in each position and watch which one holds.
request path · gate by gate
the dedup stepper · why per-op auth is affordable
If every DAL call re-checks the actor, won't that hammer the session store? No — React.cache() memoizes the lookup for the render pass, so a layout, page and three components asking "who is the actor?" run one query. Step the render tree and watch the counter. Then trip the footgun: pass a fresh object as an argument and the reference-keyed cache misses.
11 The layering rule
Entrypoints know Next. The DAL knows data.
The biggest real-world smell is one data/*.ts file that imports redirect, cookies, revalidatePath and runs SQL — tangling routing, HTTP, cache and data authorization into a knot that no other caller can reuse. The rule: navigation, cookies, cache invalidation and HTTP responses belong to the entrypoint; SQL, authorization, transactions, DTOs and invariants belong to the DAL. Sort each concern into its layer — the widget will tell you if you're building a tangle.
Why it matters: the moment your DAL calls redirect('/login'), it can no longer be reused by a Route Handler that wants a 401, a webhook with no browser to redirect, a CLI script, or a test that wants a typed error. The DAL should throw domain outcomes — UnauthorizedError, NotFoundError, ValidationError — and let each entrypoint translate them into redirect(), notFound(), or Response.json(…, { status }).
app/* entrypoints — know Next │ ▼ server/* adapters — cookies() → Actor, error maps │ ▼ data/* the DAL — SQL, authz, DTOs, tx │ ▼ db/* low-level client + schema // never: data/* → app/* or next/navigation
parse(FormData) with Zod/Valibot). Business invariants — "name ≥ 2 chars", "cart not empty", "actor owns this" — live in the DAL, because they're policy, and policy is the DAL's whole job.
12 Writes · transactions & idempotency
When an action calls three writes, who owns atomicity?
An action that calls createOrder(), then decrementStock(), then createPayment() in sequence has no answer to "what if step 2 fails after step 1 committed?" The rule: a single use-case DAL function owns the transaction; the Server Action stays thin. Step the checkout with a failure injected at each stage — watch the difference between a db.$transaction that rolls back cleanly and three loose writes that corrupt state.
webhook idempotency · the same transaction, guarded
Payment providers retry, so your endpoint may see the same event twice. The idempotency check belongs inside the same transaction — but a naïve findFirst-then-create is a check-then-act race two concurrent retries can both pass. The robust version leans on a unique DB constraint and catches the violation.
13 Caching · the three layers a DAL touches
Stop calling it "the Next cache."
App Router caching confuses people because there are several caches with different lifetimes, and this section stitches together two altitudes: the entrypoint question from §06 — who marks it stale and who sees it fresh — and the data-layer question — which of the three caches a DAL touches. Switch between them, then read the decision table.
the DAL caching decision table
| Data kind | Cache stance | Why |
|---|
cookies() and headers() must be read outside a 'use cache' scope and passed in as arguments — otherwise a per-user value gets baked into a shared, cross-request cache and served to the wrong person. Current-actor data uses React.cache() (per-request), never a persistent shared cache.
14 Where the DAL meets the OS
A connection is a file descriptor, not an abstraction.
This is where the DAL stops being architecture and becomes systems. A database connection in a Node runtime is a real TCP socket holding a real file descriptor, backed by a real server-side process and a slot in the database's connection limit. The DAL is where you keep that resource sane: a client singleton that survives dev HMR, pool math that doesn't detonate under serverless concurrency, and awareness that the Edge runtime can't open a TCP socket at all. Inspect the pool to see the resource chain.
// src/db/client.ts export const db = new PrismaClient() // save file → module re-evaluated → // new PrismaClient → old pool lingers → // "too many connections" within minutes
db.$transaction pins one pooled connection for its entire duration — long transactions starve the pool, linking §12 straight to this section. And transaction-mode poolers like PgBouncer are incompatible with prepared statements unless configured for it, a very common serverless-Postgres break. On the Edge, swap the TCP driver for an HTTP one.
15 Performance tradeoffs
Each doorway is the best at exactly one thing.
Server Component fetch is the best default read path — render the route with the data already in it. Route Handlers are the best network boundary — when the request is naturally an API call. Server Actions are the best mutation-to-UI path — when a user action changes server state and the current screen should reflect it. And TanStack Query is the best client-owned lifecycle — when loading states, refetching, polling and invalidation belong to the component.
16 The read / write split
One Data Access Layer, three consumers.
The clean default architecture: reads flow through Server Components, UI writes through Server Actions, external HTTP access through Route Handlers — and all three call the same Data Access Layer. The DAL isn't a Next.js file convention; it's an architecture pattern — import 'server-only', auth-aware, DTO-shaped — and it's what prevents the same query and auth logic from being duplicated across page.tsx, actions.ts and route.ts.
Same business logic, different entrypoints. Flip through the four files:
17 When each abstraction becomes wrong
Point the smell detector at a scenario.
Every doorway has a failure mode. Server Component fetch becomes wrong when data must update repeatedly without navigation, or other clients need the resource. A Route Handler becomes wrong when only your own Server Component calls it. A Server Action becomes wrong when the consumer isn't React UI, when you need CORS, raw webhook bodies, files or streams — or when you're tempted to use actions as a general RPC layer. Pick a scenario; the detector names the right door and shows the shape.
18 Detailed comparison
The axis table.
Every axis from the deep comparison, in one reference sheet. Hover a row; scroll sideways on small screens.
| Axis | Server Component fetch | Route Handler | Server Action |
|---|
19 Concrete decision tree
Answer four questions. Get a doorway.
The same checks, in the order that resolves fastest: render-need first, UI-mutation second, URL-need third, client-lifecycle last. Walk it for the feature you're building right now.
20 Other ways to fetch data in Next.js
Eight fetch paths, A through H.
Yes, there are more than three. The doorways above are the entrypoints; these are the concrete fetching techniques that flow through them — including the newer promise-over-the-boundary trick and the two build-time fetchers that are not general data layers.
21 Practical App Router rule
Where the files go, and why.
For a serious Next.js app, the structure falls straight out of the doorways. Add the api/ folder only when a real HTTP consumer exists; add TanStack Query only when a Client Component genuinely owns an async lifecycle.
the cleanest mental model
/api for everything. Start with Server Components for reads, Server Actions for UI mutations, Route Handlers for actual HTTP APIs, and TanStack Query when the client owns the async lifecycle.
And the shorthand rule, doorway by doorway:
Read for page render? → Server Component + DAL Write from a form/button + RSC refresh? → Server Action + DAL + revalidatePath Need a URL-addressable API? → Route Handler + DAL Highly interactive client refetching? → Client Component + TanStack Query + Route Handler Client-side mutation lifecycle? → useMutation + Route Handler + invalidateQueries() Webhook / raw body / file / stream / CORS? → Route Handler