Goodbye API Routes: Mastering Next.js Server Actions
In the history of Next.js, versions 13.4 (Stable App Router) and 14 represent massive milestones. Among all the new features, the most revolutionary—and perhaps the most disruptive to traditional backend patterns—is Server Actions.
Before Server Actions, handling a simple form submission in Next.js (like adding a Todo) was a friction-filled process:
- Define the API: Write a POST handler in
pages/api/todo.ts. - Glue Code: Write
onSubmitin your component, riddled withfetch('/api/todo'). - State Management: Manually track
isLoading,isError, anddatausinguseState. - Type Syncing: Share types between frontend and backend to ensure safety.
It was tedious and fragmented. Even though your logic lived in the same project, your mental model was split.
With stable Server Actions, everything changed. We can now call backend logic as if it were a local function. Next.js handles the underlying HTTP communication, serialization, and deserialization. It’s effectively a return to the RPC (Remote Procedure Call) pattern—but this time, it’s type-safe and deeply integrated with the React lifecycle.
1. What are Server Actions?
In short, Server Actions are asynchronous functions that run on the server. You can define them directly inside Server Components or import them into Client Components.

The magic lies in the 'use server' directive. When the Next.js compiler sees this, it automatically creates a hidden API endpoint and triggers it via a POST request whenever the function is called on the client.
2. Practical Implementation: Building a Production-Grade Todo Form
Our requirements:
- Mutation: Write data to a database.
- Validation: Use Zod for server-side integrity.
- Feedback: Provide status messages (success/failure).
- Revalidation: Automatically update the UI upon success.
Step 1: Define the Action (actions.ts)
Best practice is to keep actions in a separate file to maintain a clean separation of concerns.
'use server'
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { db } from '@/lib/db'; // Your database instance (e.g., Prisma)
// 1. Define Validation Schema
const schema = z.object({
todo: z.string().min(3, "Minimum 3 characters required").max(100, "Too long"),
});
// Define Return State Type
export type FormState = {
message: string;
errors?: {
todo?: string[];
};
success?: boolean;
}
export async function createTodo(prevState: FormState, formData: FormData): Promise<FormState> {
// Simulate network delay for development visualization
await new Promise(resolve => setTimeout(resolve, 500));
// 2. Data Parsing & Validation
const rawData = {
todo: formData.get('todo'),
};
const parse = schema.safeParse(rawData);
if (!parse.success) {
return {
message: 'Validation failed',
errors: parse.error.flatten().fieldErrors,
success: false
};
}
// 3. Database Operation
try {
await db.todo.create({
data: {
title: parse.data.todo,
completed: false
}
});
// 4. Revalidation (Crucial!)
// Tells Next.js to purge the cache and refetch the latest data
revalidatePath('/');
return { message: 'Todo added successfully', success: true };
} catch (e) {
console.error(e);
return { message: 'Internal Database Error', success: false };
}
}
Step 2: Build the Client Component (AddTodo.tsx)
We use the useActionState (previously useFormState) Hook from React to handle the server-side result. This ensures progressive enhancement—your form works even before JS has hydrated.
'use client'
import { useActionState } from 'react';
import { createTodo } from '@/app/actions';
import { SubmitButton } from './SubmitButton';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
const initialState = {
message: '',
};
export function AddTodo() {
const [state, formAction] = useActionState(createTodo, initialState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
toast.success(state.message);
formRef.current?.reset();
} else if (state.message && !state.errors) {
toast.error(state.message);
}
}, [state]);
return (
<form ref={formRef} action={formAction} className="flex flex-col gap-2 max-w-md mx-auto mt-10">
<div className="flex gap-2">
<input
type="text"
name="todo"
className="border p-2 rounded flex-1 text-black"
placeholder="What needs to be done?"
aria-describedby="todo-error"
/>
<SubmitButton />
</div>
{state?.errors?.todo && (
<p id="todo-error" className="text-red-500 text-sm font-medium">
{state.errors.todo[0]}
</p>
)}
</form>
);
}
Step 3: Handling Pending States (SubmitButton.tsx)
Avoid manual loading states! Use the useFormStatus Hook. Note: This Hook must be used within a child component of the <form> to function.
'use client'
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-indigo-600 text-white p-2 rounded disabled:opacity-50 flex items-center justify-center min-w-[80px] font-semibold"
>
{pending ? <Loader2 className="animate-spin h-5 w-5" /> : 'Add'}
</button>
);
}
3. Advanced: Optimistic UI Updates
Modern web apps are judged by their perceived speed. Users hate waiting for spinners. Even with a 100ms response time, we can make the interface feel like 0ms latency.
The useOptimistic Hook enables this premium experience.

Consider a Todo List item:
'use client'
import { useOptimistic } from 'react';
import { deleteTodo } from '@/app/actions';
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticUpdate] = useOptimistic(
todos,
(state, idToRemove: string) => state.filter(t => t.id !== idToRemove)
);
return (
<ul className="space-y-2">
{optimisticTodos.map((t) => (
<li key={t.id} className="flex justify-between items-center bg-zinc-50 dark:bg-zinc-900 px-4 py-2 border rounded-lg shadow-sm">
<span>{t.title}</span>
<form action={async () => {
// 1. Immediate UI update (Synchronous)
addOptimisticUpdate(t.id);
// 2. Real server request (Asynchronous)
await deleteTodo(t.id);
}}>
<button type="submit" className="text-rose-500 text-sm font-medium hover:underline">
Delete
</button>
</form>
</li>
))}
</ul>
);
}
With useOptimistic, the item vanishes the millisecond you click delete.
- If the server succeeds, the UI remains unchanged (as the refetched data from
revalidatePathmaps to the new state). - If the server fails, Next.js automatically rolls back the state, and the item reappears.
4. Pitfalls & Best Practices
Q1: Are Server Actions secure?
Inherently safe, but require vigilance. Server Actions are public endpoints. Anyone can guess the URL and send a request.
- Authentication is mandatory: Always verify user identity first (e.g.,
await getSession()). Never trust auserIdpassed from the frontend; always retrieve it from the server session. - Input Sanitization: Always use Zod or similar libraries to validate every field.
Q2: Separation of Concerns
While technically possible to define actions within a component, it often leads to bloated bundles and hydration errors.
Best Practice: Always keep actions in a dedicated actions.ts file or an actions/ directory.
Q3: Server Actions vs. API Routes
- Server Actions: Best for mutations (POST/PUT/DELETE) where logic is tightly coupled to the UI.
- API Routes (Route Handlers): Best for external integrations (Webhook callbacks), REST APIs for mobile apps, or complex GET requests (file streaming).
Conclusion
Server Actions represent the missing puzzle piece in the Next.js ecosystem. They eliminate glue code and allow developers to build full-stack interfaces with unprecedented efficiency. When combined with revalidatePath and useOptimistic, you can create web applications that feel like native apps with minimal effort.