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:
// 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:
// 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:
// 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:
// 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:
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:
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:
// 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:
// 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:
// 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
revalidatePathorrevalidateTag. 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
nameattribute are included in FormData. Forget the name, the value disappears. - Mixing useActionState signatures: Old code uses
useFormState. New code usesuseActionState. 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
actionprop. Forms work without JavaScript because they're plain HTTP submissions. - Use
useActionStateto manage pending state and error messages in the client. The hook wraps your action and gives youisPendingand return state. - Always call
revalidatePathorrevalidateTagafter 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
prevStateparameter in your action to accumulate state or handle retries.






Comments
Loading comments…