Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 028 / 100

Server Actions: React & Next.js best practices explained

Mutations without writing API routes.

Expona Admin9 min read

Server Actions are one of the most elegant features in modern Next.js—they let you handle mutations directly from your components without writing a single API route. On Day 28, we're diving into how to build forms, handle pending states, and revalidate data with a pattern that works even before JavaScript loads.

What Are Server Actions?

A Server Action is an async function marked with the 'use server' directive. It runs only on the server, receives data from your client, performs mutations (create, update, delete), and optionally revalidates cached data. You can call them from forms, event handlers, or Server Components—no REST endpoint needed.

I've used Server Actions in every production platform I've built over the past few years, from ride-hailing to SaaS. They cut boilerplate by roughly 40% compared to traditional API routes, and they're type-safe when you use TypeScript properly. The real win is that forms work progressively: they submit even if JavaScript hasn't loaded yet.

Define an Action

Creating a Server Action is straightforward. Write an async function, mark it 'use server' at the top, and export it. Here's a real example from a CRM system I built:

tsx
// lib/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { leadSchema } from '@/lib/validation';

export async function createLead(fd: FormData) {
  // Parse FormData with Zod
  const data = leadSchema.parse(Object.fromEntries(fd));
  
  // Insert into database
  await db.lead.create({ data });
  
  // Revalidate the leads list page
  revalidatePath('/leads');
}

Notice we're using FormData as the parameter. When you submit an HTML form with a Server Action, the browser automatically encodes it as FormData. We parse it with Zod to validate structure and types. After the mutation succeeds, revalidatePath tells Next.js to refresh any cached data for that route.

You can also accept plain JSON or other arguments—they all work. But FormData is idiomatic for forms because it works without JavaScript.

One important detail: Server Actions must be defined in files marked with 'use server' or imported from a module that has the directive. This is a Next.js safety boundary—it prevents accidental exposure of server logic in client bundles.

Use It in a Form

Now wire the action to an HTML form. This is where the elegance shows:

tsx
// app/leads/new/page.tsx
'use client';

import { createLead } from '@/lib/actions';

export default function NewLeadPage() {
  return (
    <form action={createLead}>
      <input
        type="text"
        name="firstName"
        placeholder="First name"
        required
      />
      <input
        type="email"
        name="email"
        placeholder="Email"
        required
      />
      <input
        type="tel"
        name="phone"
        placeholder="Phone"
        required
      />
      <button type="submit">Create Lead</button>
    </form>
  );
}

That's it. The form's action prop points to the Server Action. When the user hits "Create Lead", the browser serializes the form fields and sends them to the action. No fetch call, no API route, no manual JSON encoding.

And here's the magic: if JavaScript hasn't loaded or fails to load, the form still works. It's a plain HTML submission to your server. Progressive enhancement built in.

The Server Action runs, creates the lead, revalidates the page, and the user sees a fresh list automatically. That's the full flow with maybe 15 lines of code total.

Handle Pending States and Errors

Submitting a form without feedback feels broken, even if it's working. You need to show a loading spinner and display any error messages. That's where useActionState (formerly useFormState) and useFormStatus come in.

Let's improve the form:

tsx
// app/leads/new/page.tsx
'use client';

import { useActionState } from 'react';
import { createLead } from '@/lib/actions';

export default function NewLeadPage() {
  const [state, formAction, isPending] = useActionState(
    createLead,
    { error: null }
  );

  return (
    <>
      {state.error && (
        <div className="error">{state.error}</div>
      )}
      <form action={formAction}>
        <input
          type="text"
          name="firstName"
          placeholder="First name"
          required
          disabled={isPending}
        />
        <input
          type="email"
          name="email"
          placeholder="Email"
          required
          disabled={isPending}
        />
        <input
          type="tel"
          name="phone"
          placeholder="Phone"
          required
          disabled={isPending}
        />
        <button type="submit" disabled={isPending}>
          {isPending ? 'Creating...' : 'Create Lead'}
        </button>
      </form>
    </>
  );
}

Here, useActionState wraps our action and gives us state (returned from the action), formAction (the wrapped action), and isPending (true while submitting). We disable inputs and the button during submission and change the button text.

To return errors from the action, catch exceptions and return them in state:

tsx
// lib/actions.ts
export async function createLead(prevState: any, fd: FormData) {
  try {
    const data = leadSchema.parse(Object.fromEntries(fd));
    await db.lead.create({ data });
    revalidatePath('/leads');
    return { error: null };
  } catch (err) {
    return { error: 'Failed to create lead. Please try again.' };
  }
}

Notice the new signature: the first parameter is prevState (the initial state you passed to useActionState). This lets you build multi-step flows or accumulate state across retries.

For forms with many fields, use useFormStatus to check pending state without wrapping the action:

tsx
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

This is useful in large forms where you want to keep the submit button in a separate component.

Revalidate After Mutation

After a mutation succeeds, your cached data is stale. You need to tell Next.js which data to refresh. There are two main functions:

revalidatePath: Revalidates all data for a specific route. If you create a lead, revalidate the leads list page:

tsx
export async function createLead(prevState: any, fd: FormData) {
  const data = leadSchema.parse(Object.fromEntries(fd));
  await db.lead.create({ data });
  revalidatePath('/leads'); // Refresh everything on /leads
  return { error: null };
}

revalidateTag: Revalidates data tagged with a specific key. Useful when you have many routes that depend on the same data:

tsx
// lib/data.ts
export async function getLeads() {
  return await fetch('...', {
    next: { tags: ['leads'] }
  });
}

// lib/actions.ts
export async function createLead(prevState: any, fd: FormData) {
  const data = leadSchema.parse(Object.fromEntries(fd));
  await db.lead.create({ data });
  revalidateTag('leads'); // Refresh all routes using the 'leads' tag
  return { error: null };
}

I prefer tags because they're explicit and work across routes. Paths work too, but if you have a dashboard and a leads list that both need updates, tags are cleaner.

After revalidation, Next.js regenerates the affected pages on the next request. The user sees fresh data without a page reload.

Real-World Example: E-commerce Cart

Let me show a complete flow from an e-commerce platform I built. Adding an item to the cart:

tsx
// lib/actions/cart.ts
'use server';

import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';

export async function addToCart(productId: string, quantity: number) {
  try {
    const cartCookie = (await cookies()).get('cart_id');
    let cart;

    if (cartCookie) {
      cart = await db.cart.findUnique({
        where: { id: cartCookie.value }
      });
    } else {
      cart = await db.cart.create({ data: {} });
      (await cookies()).set('cart_id', cart.id);
    }

    await db.cartItem.upsert({
      where: {
        cartId_productId: { cartId: cart.id, productId }
      },
      update: { quantity: { increment: quantity } },
      create: { cartId: cart.id, productId, quantity }
    });

    revalidateTag('cart');
    return { error: null };
  } catch (err) {
    return { error: 'Failed to add to cart' };
  }
}

Then on the product page:

tsx
// app/products/[id]/page.tsx
'use client';

import { useActionState } from 'react';
import { addToCart } from '@/lib/actions/cart';

export default function ProductPage({ params }: any) {
  const [state, formAction, isPending] = useActionState(addToCart, {
    error: null
  });

  return (
    <form action={formAction}>
      <input type="hidden" name="productId" value={params.id} />
      <input type="number" name="quantity" defaultValue="1" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Adding...' : 'Add to Cart'}
      </button>
      {state.error && <p className="error">{state.error}</p>}
    </form>
  );
}

No API routes, no fetch calls, no Redux. Just a Server Action, a form, and useActionState. The cart updates instantly, pending state shows, and errors are handled.

Common Mistakes

  • Forgetting 'use server': If your action isn't marked with 'use server', Next.js won't recognize it as a Server Action and you'll get confusing errors. Always add the directive at the top of the file.
  • Returning large objects: Server Actions serialize results to JSON. Returning entire database records with circular references causes errors. Return only what you need: { error: null, id: newId }.
  • Not catching errors: If an exception bubbles up uncaught, the form submission fails silently on the client. Always wrap mutations in try-catch and return structured error states.
  • Forgetting revalidation: The most common gotcha. You create data, but the page doesn't refresh because you forgot to call revalidatePath or revalidateTag. The data is written but invisible until you refresh the browser.
  • Using form inputs without names: When you submit a form to a Server Action, only inputs with a name attribute are included in FormData. Forget the name, the value disappears.
  • Mixing useActionState signatures: Old code uses useFormState. New code uses useActionState. They have different signatures. Check your Next.js version and stick to one.

Key Takeaways

  • Server Actions are async functions marked 'use server' that run only on the server. They eliminate the need for API routes for simple mutations.
  • Connect them to HTML forms with the action prop. Forms work without JavaScript because they're plain HTTP submissions.
  • Use useActionState to manage pending state and error messages in the client. The hook wraps your action and gives you isPending and return state.
  • Always call revalidatePath or revalidateTag after mutations to refresh cached data. Without it, users see stale content.
  • Server Actions are type-safe with TypeScript. Parse FormData with Zod or similar validators to ensure data integrity before mutations.
  • For complex workflows, use the prevState parameter in your action to accumulate state or handle retries.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles