Should you proxy through Server Actions or call APIs directly?
When a Next.js app talks to a separate backend, you can route calls through Server Actions or hit the API directly from the client. Here's how to decide which fits your project.
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.
Two ways to talk to your backend
Frame it as an architecture decision and it splits into two options.
- 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 are the trade-offs.
How each pattern works
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, Server Actions are API routes Next.js generates for you. Called from the client, they always go 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.
The trade-offs
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 revalidateTag updates the UI. 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.
Performance
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.
Server Actions also run sequentially. Trigger several mutations at once and they queue up and run one after another while the UI waits. Direct calls can fire with Promise.all and 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.
Scaling
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.
Security
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.
Choosing
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.
Mixing 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.
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 clears the cache for one specific route, so the next visit to that path re-renders with fresh data. revalidateTag works differently, it clears every cached fetch call anywhere in the app tagged with that value, regardless of which page it lives on. Reach for revalidateTag when the same data shows up in multiple places, and revalidatePath when you only need to refresh the page you're already on.
Can I run several Server Actions in parallel with Promise.all?
Not really, at least not the way you'd hope. Server Actions triggered from the client run sequentially on the server, there's no setting to change that. 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.