How to Fetch with Next.js Server Actions
Use Next.js server actions to mutate data from the client so the fetch is type-safe, the payload is validated, and the cache is revalidated on success.
On this page
Introduction
The hardest part of mutate from client safely is not writing the code — it is deciding what the code is allowed to reject. A server actions setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in server actions, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
The cost of mutate from client safely without server actions is paid in incidents, not in code. The code looks fine because the happy path works, and the happy path is what you demo. The failure path is what you operate, and the failure path is where server actions earns its keep. Instrument the rejections, alert on the spikes, and let the metric tell you when the guard is misaligned with reality.
The sections below walk through the concrete decisions: where to put the server actions check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Define the action
Define the action is the first decision because it determines what the rest of the server actions pipeline can assume. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Define the action is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
'use server';
export async function updateName(input: FormData) {
const name = NameSchema.parse(input.get('name'));
await db.update(name); revalidatePath('/user');
return { ok: true };
}
The typescript above is the shape to ship for mutate from client safely. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Validate the input
The mechanism behind Validate the input is straightforward in server actions, but the consequences ripple. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Validate the input is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Mutate the data
Mutate the data is where most server actions implementations leak performance. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Mutate the data is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('revalidates path', async () => {
await updateName(fd);
expect(revalidatePath).toHaveBeenCalledWith('/user');
});
Pin the behavior with this test. A server actions setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Revalidate the path
The hard part of Revalidate the path is not the happy path — it is what happens when server actions fails halfway. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Revalidate the path is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Return typed result
Return typed result should be observable without a debugger. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Return typed result is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test the action
Test the action is a trade, not a winner. A wrong choice here turns into mutate from client safely under load, and the invariant you thought server actions guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test the action is the one most teams skip: drive server actions to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in mutate from client safely all reduce to one mistake: treating server actions as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the server actions boundary so mutate from client safely enters unvalidated.
- Assuming server actions is fast without measuring the tail latency under load.
- Letting mutate from client safely fail silently instead of failing loudly with a metric.
- Testing only the happy path so the server actions failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: server actions fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives server actions to the failing condition and assert the system degrades gracefully. Then mutate from client safely is a property you maintain, not a feature you hope for.
Sources: Next.js Server Actions, revalidatePath
FAQ
- Why server actions?
- Type-safe, no API route boilerplate, and progressive enhancement works without JS.
- Validation?
- Always validate the input with Zod. Never trust the FormData.
Report a problem with this articleOpens a pre-filled issue on GitHub.