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.

By Satish Kumar December 14, 2025

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:

  1. Create an API Route (/api/update-user).
  2. Write a fetch request in a Client Component.
  3. Handle the isLoading state manually.
  4. Handle the error state manually.
  5. 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?

  1. Request: Browser sends POST to Server Action.
  2. Mutation: DB is updated.
  3. Revalidation: Next.js re-runs the logic for /feed on the Server.
  4. Response: The Server sends back A New RSC Payload (The UI Tree).
  5. 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:

  1. React intercepts the submit event.
  2. pending becomes true.
  3. The request goes to the server.
  4. The server responds.
  5. pending becomes false.

Summary

  1. RPC: Server Actions allow you to call backend functions from the frontend as if they were local.
  2. Resilience: Using <form action> allows your app to work without JavaScript.
  3. The Loop: You don't update client state manually. You mutate the DB, call revalidatePath, and the Server sends you the updated UI.
  4. Pending States: Use useFormStatus to 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>
  );
}

Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.