render·endpoint·action  three doorways

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.

▤ Same server, different doorways
NEXT.JS SERVER GET /dashboard fetch('/api/…') <form action> HTTP only Browser navigations · fetches · submits Everyone else webhook · mobile · curl · cron ① Server Component render "I need data to render this route" · page.tsx / layout.tsx ② Route Handler "I need an HTTP endpoint" · app/api/orders/route.ts ③ Server Action "a React interaction runs server code" · 'use server' ◆ THE POLICY LAYER Data Access Layer import 'server-only' who may read · who may write which fields leave · what's atomic what may be cached DB / API orders · products · users what comes back out: ① HTML + RSC Payload ② Response · JSON / stream / file ③ result + updated RSC Payload
knock on a door — watch what travels the wire, and what the policy layer checks
— the wire is quiet · press a button above —
◈ Click any box for an interactive deep dive · hover to highlight · Inspect to pan & zoom
Server Component · render reads Route Handler · HTTP boundary Server Action · React mutation bridge TanStack Query · client async state DAL · the policy layer behind all three

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 × policy click a cell
THE ORTHOGONALITY

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

why this matters up front Hold both axes in mind and every later section is just filling in the grid: §02–07 teach the columns (contract), §08–14 teach the rows (policy), and §15–21 read the whole matrix back to you as tradeoffs, smells, and a decision tree.

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.

the difference in one line A Route Handler says 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.

▶ wire inspector · replay the traffic
the wire, frame by frame

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.

⚙ the 'use server' split · one module in, two bundles out
source · app/orders/actions.ts
the compiler is about to swap your function for a reference
server bundle · keeps the implementation
client bundle · gets a reference
Route Handler · filesystem discovery
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
Server Action · compiler recognition
'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 URL
the security consequence The implementation stays server-side, but the action is still reachable through a network request. Treat every Server Action as an untrusted server entrypoint, never as a private local function — section 06 makes this concrete.

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

▶ create a product · TanStack + Route Handler vs Server Action
useMutation → Route Handler → invalidate0 roundtrips
<form action> → Server Action → revalidate0 roundtrips
00 / 05

NEITHER IS UNIVERSALLY BETTER  They optimize for different ownership. In the TanStack loop the client owns the mutation lifecycle, the Route Handler owns the HTTP mutation, and TanStack Query owns refetch/invalidation. In the action loop React/Next owns the lifecycle, the action owns the mutation, and Next can hand back updated UI directly.

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.

less ideal · an HTTP hop to yourself
Server Component
        │  fetch('https://my-app.com/api/products')
        ▼
Route Handler        ← extra hop, extra serialization
        ▼
DB
better · straight through the DAL
Server Component
        ▼
server-only data function   // import 'server-only'
        ▼
DB

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

the dangerous mental model
"This button is only rendered for admins,
 so the action is safe."

// the UI is a rendering decision,
// not a security boundary
the correct mental model
"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.

attack console · deleteOrder(orderId)
send a request as…

server log

— pick an attacker —
lock it in Server Actions have framework-level protections (unguessable IDs, origin checks) — but those do not replace application-level auth, authorization, validation and ownership checks. Same session check, same ownership query, whichever door the request came through.
this is the bridge The guarded code above does its checks in the entrypoint. That works — but if 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.

▤ scattered policy vs one layer six responsibilities, N places

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.

the responsibilities it owns Authentication context · authorization · data fetching · DTO shaping · input validation · mutation invariants · transaction boundaries · cache strategy · a typed error policy. Nine jobs — in one auditable place instead of scattered across the route tree.

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:

the poison pill · package.json exports
what it does — and doesn't — do
✓ 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 gap it leaves — why §10 exists The poison pill protects the import graph. But a Route Handler or Server Action is a runtime entrypoint reached by a raw POST that never statically imports anything client-side — so module protection says nothing about who calls it. That's precisely why authorization has to live at the data layer, not the bundle.

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.

leak inspector · <ClientProfile user={…} />

the DAL returns


          

what a user sees in DevTools → Network → the RSC payload

the DTO mapper · field-level authorization
the taint rail · a second line of defense
the protection stack, in order 1 · the DAL returns only allowed fields → 2 · the DTO has an explicit return type → 3 · field-level auth in the mapper → 4 · 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.

auth-depth replay · GET /invoices/inv_999 (not yours)
send

request path · gate by gate

— send a request —

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.

▶ getCurrentActor() across one render tree 0 queries
00 / 05

the memoized auth adapter
the DAL receives the actor explicitly

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.

▤ sort each concern into its layer

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 }).

clean dependency direction
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
DAL throws, entrypoint translates
where validation splits Shape validation — is this even a well-formed request — is parsed at the entrypoint (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.

▶ checkout · 3 writes, 1 transaction inject a failure:
00 / 05

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.

TOCTOU · two retries both pass the check
let the database arbitrate

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 kindCache stanceWhy
the one rule that prevents leaks 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.

▤ process → pool → fd → backend one process, one pool

dev HMR leaks pools
// src/db/client.ts
export const db = new PrismaClient()

// save file → module re-evaluated →
// new PrismaClient → old pool lingers →
// "too many connections" within minutes
the globalThis singleton
the two systems facts worth carrying A 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.

Server Component fetch · read for render
+no client data-fetching JS required
+secrets stay server-side · direct DB access
+works with streaming / Suspense · great SEO & first render
+avoids the internal HTTP hop
−render can block on slow data unless streamed
−dynamic data can make the route dynamic
−not a reusable public API · interactive refetch needs help
Route Handler · HTTP boundary
+explicit HTTP resource · usable by non-React clients
+webhooks · mobile · external integrations
+full control: status, headers, CORS, streams
+polling, infinite scroll, search, exports
−extra hop if called from your own Server Component
−no automatic RSC UI patch · you manage invalidation
−you design & maintain contracts · easy to duplicate DAL logic
Server Action · mutation-to-UI
+no manual API endpoint needed
+progressive form-friendly model
+mutation + UI refresh in one roundtrip
+great with revalidatePath · redirect · refresh
−not a public/stable API contract · framework protocol
−action IDs can change across deployments
−client dispatch is sequential · not for high-frequency reads
TanStack Query · client lifecycle
+query-key ownership · loading / error / stale states
+polling · retries · infinite lists · optimistic updates
+invalidateQueries → precise, targeted refetching
−needs an HTTP source — usually a Route Handler
−ships client JS · another cache to reason about
−overkill when data is only needed for initial render
the sequential-dispatch detail Server Functions are designed for mutations, and the client currently dispatches and awaits them one at a time. If you need parallel data fetching, use Server Component fetching, a single action doing parallel work internally, or a Route Handler — never a fan-out of actions.

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.

▤ the architectural map
Data Access Layer server-only · auth-aware · DTO-shaped Server Components read initial route data page.tsx · layout.tsx Server Actions UI-owned mutations actions.ts · 'use server' Route Handlers HTTP-owned resources app/api/**/route.ts HTML + RSC Payload updated RSC Payload JSON / stream / file TanStack Query client useQuery · useMutation · invalidateQueries
◈ Click any box for its deep dive — the DAL is what prevents duplication

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.

▶ scenario → verdict

18 Detailed comparison

The axis table.

Every axis from the deep comparison, in one reference sheet. Hover a row; scroll sideways on small screens.

AxisServer Component fetchRoute HandlerServer 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.

? four questions, one verdict

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.

project layout

the cleanest mental model

Server Component fetch= data for rendering Route Handler= HTTP API boundary Server Action= React mutation bridge TanStack Query= client-side async state / cache manager Data Access Layer= where your real business / data rules should live
the practical line Do not start with /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
Back to JIT-Atlas