The Loop: Server Actions & The RPC Revival
Forget API routes. Learn how Server Actions allow you to treat the backend like a local function, how Progressive Enhancement works in 2026, and the mechanics of Cache Revalidation.
Part of the series: The Senior Engineer's Guide to React Server Components
- Part 1The Architect & The Builder: React’s Internal Split
- Part 2The Wire & The Wall: Serialization & The Flight Protocol
- Part 3The Cost of Waking Up: Hydration & The Uncanny Valley
- Part 4The Great Split: Zero-Bundle-Size Architecture
- Part 5The Hole in the Donut: RSC Composition Patterns
- Part 6Breaking the Waterfall: Streaming & Suspense
- Part 7The Loop: Server Actions & The RPC Revival
- Part 8The Senior Playbook: Architecture, Patterns & Caching
We have spent six articles building a highly efficient reading machine. The Architect (Server) calculates the UI, streams it to the Builder (Client), and skips hydration for static content.
But a read-only website is just a brochure. Real applications need to Mutate Data.
In the "Old World" (Single Page Apps), writing data was a chore:
- Create an API Route (
/api/update-user). - Write a
fetchrequest in a Client Component. - Handle the
isLoadingstate manually. - Handle the error state manually.
- The Hardest Part: Update the local Redux/React Query cache so the UI reflects the change.
Server Actions delete this entire workflow. They bring us back to the simplicity of the 1990s (Forms) but with the power of modern React.
The Return of RPC (Remote Procedure Calls)
In computer science history, Birrell and Nelson (1984) introduced RPC. The idea was simple: What if calling a function on a remote computer looked exactly like calling a function on your local computer?
React Server Actions are the modern evolution of RPC.
Instead of thinking about "HTTP Methods" and "JSON Serialization," you just think about Functions.
// actions.ts
'use server'; // 👈 The Compiler Instruction
export async function updateUser(formData: FormData) {
const name = formData.get('name');
await db.user.update({ where: { id: 1 }, data: { name } });
}When you import this function into a Client Component and call it, Next.js performs a magic trick. It doesn't bundle the code. Instead, it creates a hidden API endpoint. When you call the function, it sends a POST request to the server, runs the logic, and sends the result back.
The Form: Progressive Enhancement
The most robust way to invoke a Server Action is using the HTML <form> element.
This isn't just for style. By using action={fn}, we unlock Progressive Enhancement. This form works even if JavaScript is disabled on the browser.
// app/settings/page.tsx (Server Component)
import { updateUser } from './actions';
export default function SettingsPage() {
return (
<form action={updateUser}>
<input name='name' type='text' />
<button type='submit'>Save</button>
</form>
);
}The Senior Engineer's Perspective:
In the CSR era, we broke the web. If the JS bundle failed to load, the <button onClick={submit}> was dead.
In the RSC era, we fix the web. The form submits via standard HTTP. The Server Action runs. The page reloads. The data updates. It is resilient by default.
The Loop: Revalidation
This is the most critical concept in Article 7.
In the old world, when you updated data, you had to manually update the Client State:
setUsers([...users, newUser]).
In the RSC world, The Server holds the State. The Client is just a reflection of the Server. So, when we update the database, we don't update the client cache manually. We tell the Architect to Redraw the Blueprints.
We use revalidatePath.
// actions.ts
'use server'
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
await db.post.create({ ... });
// 🗣️ "Hey Architect, the /feed page is stale. Redraw it."
revalidatePath('/feed');
}What happens technically?
- Request: Browser sends POST to Server Action.
- Mutation: DB is updated.
- Revalidation: Next.js re-runs the logic for
/feedon the Server. - Response: The Server sends back A New RSC Payload (The UI Tree).
- Merge: The Browser receives the new tree and updates the DOM intelligently (preserving focus and scroll states).
You never have to manually setPosts(). The Server pushes the fresh UI to you.
UX: Handling Loading States
Since forms can work without JS, how do we show a "Spinner"?
We use the useFormStatus hook. Because hooks only run on the client, we extract the button to a "Leaf" component.
// SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>{pending ? 'Saving...' : 'Save Changes'}</button>
);
}Now, when the form submits:
- React intercepts the submit event.
pendingbecomestrue.- The request goes to the server.
- The server responds.
pendingbecomesfalse.
Summary
- RPC: Server Actions allow you to call backend functions from the frontend as if they were local.
- Resilience: Using
<form action>allows your app to work without JavaScript. - The Loop: You don't update client state manually. You mutate the DB, call
revalidatePath, and the Server sends you the updated UI. - Pending States: Use
useFormStatusto add interactivity to the progressive forms.
We have now covered the entire lifecycle: Render, Serialize, Hydrate, Stream, and Mutate.
But knowing how the pieces work isn't enough to build a scalable application. You need to know how to organize them. In the final article, we open The Senior Playbook—a collection of architectural patterns, caching strategies, and best practices used by top engineering teams.
Challenge: The Optimistic Update
You have a "Like" button. Currently, when you click it, it takes 500ms for the server to respond and the heart to turn red. This feels sluggish.
Task:
Use the useOptimistic hook (React 19) to make the heart turn red instantly, while the Server Action runs in the background. If the server fails, the heart should revert to white automatically.
Starter Code:
'use client';
import { toggleLike } from './actions';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
// Task: Replace this with useOptimistic
const [likes, setLikes] = useState(initialLikes);
return (
<form action={toggleLike}>
<button>{likes} ❤️</button>
</form>
);
}Click to Reveal Solution
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
// 1. Define the Optimistic State
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, newLike: number) => state + newLike
);
return (
<form
action={async () => {
// 2. Update UI Instantly
addOptimisticLike(1);
// 3. Trigger Server Action (Background)
await toggleLike();
}}
>
<button>{optimisticLikes} ❤️</button>
</form>
);
}