You've got a Next.js app, and it needs to talk to a backend that isn't Next.js, maybe a Python service, a Rails app, or plain Express. The first architectural decision you'll make, often without realizing it's a decision at all, is where that connection actually lives.
Some teams route every request through a Server Action and let Next.js sit in the middle. Others skip that step and have the browser call the backend directly. Both are common, both work, and picking wrong doesn't blow up right away. It just shows up later as latency nobody budgeted for, or a security gap nobody planned for.
This post walks through how each pattern works, then digs into the real trade-offs around performance, scaling, and security, so you can pick deliberately instead of by habit. It's written for developers who've built a Next.js app or two. You don't need to have used Server Actions before, the next section explains what they are.
There are two places the connection can live
The connection to your backend either runs through the Next.js server or skips it. A Server Action is an async function marked with 'use server' that runs on the Next.js server, but that a client component can call like a normal function. That makes it a natural middleman.
- Pattern A: Server Actions as a proxy. The Next.js app becomes a middleman. Client components call Server Actions, which forward requests to the backend. This is the Backend-for-Frontend (BFF) approach.
- Pattern B: direct client-side calls. The browser talks straight to the backend, the same way client-side rendering has always worked.
Here's how a request travels in each.
A proxy adds a hop, a direct call skips it
The proxy pattern sends every request through the Next.js server on its way to the backend, while a direct call goes straight from the browser.
Server Actions as a proxy
- A user action in the app triggers a Server Action
- The Server Action runs on the Next.js server and calls the backend API
- The backend responds to the Next.js server
- The Server Action processes that response and sends it to the client
The path: client, Next.js server, backend API, Next.js server, client.
Under the hood, each Server Action becomes a POST endpoint on the page that uses it, identified by an action ID Next.js generates. Called from the client, it always goes over POST, regardless of what the action actually does.
Direct client-side calls
- A client component needs data
- Your JavaScript calls the backend directly with fetch or Axios
- The backend responds straight to the browser
The Next.js server serves the initial JavaScript bundle, then steps out of the way. Every request after that happens in the browser.
Each pattern wins on different things
Neither approach is better across the board. What matters is what you're optimizing for.
When Server Actions make sense
Secrets stay hidden. API keys and service tokens live on the server and never reach the browser.
Auth lives in one place. Every request can be validated against the user's session before it reaches the backend, instead of trusting each client call independently.
Client code stays simple. Components call functions instead of managing fetch calls, error states, and loading states by hand.
Cache invalidation is built in. After a mutation, calling revalidatePath or updateTag updates the UI in the same response. No manual cache bookkeeping.
When direct calls make more sense
Your backend already does the work. If the API already handles OAuth, JWTs, rate limiting, and CORS, a proxy layer adds a hop without adding much value.
Latency is the priority. Direct calls skip the extra round trip through the Next.js server, which matters for real-time features.
You want a thin frontend. If Next.js should just serve pages, direct calls keep it out of the request path entirely.
Developer experience, side by side
| What you care about | Server Action proxy | Direct client calls |
|---|---|---|
| Client-side code | Function calls | Manual fetch setup |
| Type safety | Typed at the call site, manual past the backend boundary | Manual type definitions |
| Boilerplate | Minimal, hooks manage state | useState + useEffect |
| Cache invalidation | Built in with revalidatePath | Custom solution |
That type safety row needs a caveat. "End-to-end, automatic" is the pitch when the whole stack is TypeScript, tRPC being the clearest example. The moment your backend is Python, Rails, or anything else, the automation stops at the Next.js boundary. The Server Action's own inputs and outputs are typed, but whatever the backend actually sends back still needs validating by hand, with something like Zod, unless you're maintaining matching types on both sides yourself.
Server Actions cost a hop and run one at a time
Server Actions add a network hop. There's no getting around that.
On serverless platforms like Vercel, that hop can push latency into the hundreds of milliseconds to low seconds, worse with cold starts. Direct calls go point to point, so they're faster by default.
That number is more a serverless characteristic than a Server Actions one. Run Next.js as a long-lived process next to your backend, same region, nothing scaling to zero, and the extra hop can cost single-digit milliseconds instead. If your own latency numbers look like the Vercel figures above, check what's actually adding the delay before blaming the pattern itself.
Here's a quick test. Fire three Server Actions from the client with Promise.all, each taking about a second on the server. How long until all three finish? Most people guess one second. It's closer to three.
Server Actions run one at a time. Trigger several at once and they queue up while the UI waits. The React docs say frameworks typically process one action at a time, and Next.js does. Direct calls with Promise.all really do run concurrently.
Don't use Server Actions for fetching data. Use Server Components or API routes with GET requests instead, Server Actions are built for mutations, and using them for reads gives up caching behavior you'd otherwise get for free.
A proxy centralizes load as well as logic
Centralizing data logic behind Server Actions is easier to maintain. One pattern, one place to update. The cost is that all that load lands on the Next.js server. As traffic grows, that server needs to scale with it, and on pay-per-invocation platforms the bill grows with it too.
Direct calls let frontend and backend scale independently. The Next.js layer stays thin and the backend absorbs its own load. The cost shows up later: fetching logic spreads across components, and tracing what calls what gets harder as the app grows.
A proxy hides more, but it's still attack surface
The proxy pattern is architecturally stronger on security, for a few concrete reasons:
- API keys and tokens never touch the browser
- The backend doesn't need to be publicly exposed, it can be firewalled to accept requests only from the Next.js server
- CSRF protection comes built in
- Authorization checks happen in one place, before anything reaches the backend
Going direct puts more of that burden on the backend itself:
- JWTs stored in the browser are a target. Store them wrong and an XSS attack can steal them.
- CORS has to be configured correctly, which is easy to get wrong.
- The API needs its own rate limiting, input validation, and abuse protection.
None of that is unworkable, it's just work the backend now has to do on its own.
Neither pattern is immune to real vulnerabilities. CVE-2025-55182, the "React2Shell" vulnerability, allowed remote code execution via crafted payloads sent through the same POST/action-dispatch mechanism Server Actions use. The bug lived in React's own Flight protocol deserialization, not in anyone's Server Action code, but a proxy architecture still means treating Server Actions as endpoints that need patching and secure coding practices. The Next.js server can be the point of compromise too.
That's worth sitting with, because it cuts against the "proxy is just safer" framing above. Every layer between the browser and your data is also a layer that can be exploited, and a full Next.js server running action-dispatch logic is a lot more code than a stateless API behind tight CORS and short-lived tokens. The proxy pattern reduces what the browser can reach directly. It doesn't reduce the total surface, it just moves where the risk lives.
Pick by what you're optimizing for
Reach for Server Actions as a proxy when secrets need to stay hidden, when a centralized data access layer keeps the codebase easier to reason about, or when type-safe function calls matter more than shaving off a network hop.
Reach for direct client calls when the backend already handles OAuth, rate limiting, and CORS on its own, when latency is the constraint you're optimizing for, or when Next.js should stay a thin layer that just serves pages.
Most large apps use both
Large applications often use both. Server Actions cover sensitive operations, payment processing, account settings, anything touching secrets. Direct calls handle public, cacheable data like blog posts or product listings that don't need protection.

Sensitive calls take the extra hop through a Server Action to keep secrets on the server, while public data goes direct.
That split gets you the security of a proxy where it matters and the speed of a direct call where protection isn't the point.
FAQ
Do Server Actions really only run over POST?
Yes. Every Server Action, no matter what it does internally, gets invoked as a POST request. Next.js generates a hidden action ID and routes the call through that same mechanism, which is also part of why they don't get GET-style caching for free.
How do Server Actions protect against CSRF?
Two layers, by default. POST-only requests already sidestep most CSRF vectors, since SameSite cookies block cross-site POSTs in modern browsers. On top of that, Next.js compares the request's Origin header against the Host header and rejects the call if they don't match. You can widen that with the allowedOrigins option in next.config.js if you need cross-origin calls, but each origin you add is one more thing that has to be trusted.
What's the actual difference between revalidatePath and revalidateTag?
revalidatePath invalidates one specific route, and when a Server Action calls it, the response already carries the re-rendered page. revalidateTag(tag, 'max') works by tag instead. It marks every piece of cached data with that tag as stale, wherever it's used, and serves the old version while fresh data loads in the background, so the user doesn't see their change right away. When they need to, call updateTag(tag) in the Server Action instead, which expires the tag immediately. Reach for tags when the same data shows up in multiple places, and revalidatePath when you only need to refresh one page.
Can I run several Server Actions in parallel with Promise.all?
Not really, at least not the way you'd hope. Next.js's client sends Server Actions one at a time, waiting for each to finish before sending the next, and there's no setting to change that. The server itself doesn't queue them. If you need several things to happen at once, do the parallel work inside a single Server Action with Promise.all there, or move the read side to a Server Component or route handler instead.
Why do Server Actions feel especially slow on Vercel?
Serverless functions spin down when they're idle, and spinning one back up for the next request is what shows up as a cold start. That sits on top of the network hop a Server Action already adds. Keeping functions warm or moving latency-sensitive mutations closer to the edge helps, but it doesn't remove the extra hop.
Should I ever use a Server Action to fetch data?
Generally no. Server Actions are built for mutations, and fetching through one gives up the caching you'd get from a Server Component or a GET-based route handler. If a component just needs to read data, reach for those instead and save Server Actions for the writes.
Wrapping up
If you're optimizing for security and maintainability, the proxy pattern is the better default, even with the added latency. If you're optimizing for speed and a fully decoupled frontend, direct calls get you there, provided the backend can stand on its own as a public-facing API.
Most real applications end up mixing both rather than picking one architecture for everything. The important part is making that choice deliberately, based on what a given feature actually needs, rather than copying whatever pattern the last tutorial used.
Originally published at Notes by Sachintha.


