Next.js Server Actions: The Complete Guide
If you’ve been building Next.js applications for a while, you know the drill. Someone asks for a simple contact form. You create an API route, set up a fetch request from the client, handle the response, manage loading states, deal with errors, maybe add some toast notifications… An hour later, you’ve written 150 lines of code for what should be a 10-line problem.
I remember the first time I shipped a Next.js app with a dozen forms. The app/api folder was a graveyard of nearly-identical route handlers, each one a monument to boilerplate code. It worked, sure, but every time I needed to add another form, I felt like I was solving the same problem for the tenth time.
Then I discovered Server Actions, and everything changed.
Server Actions are one of those rare features that make you rethink how you build applications. They let you call server-side functions directly from your components—no API routes, no fetch calls, no manual loading states, no endpoint setup. Just functions that run securely on the server and seamlessly integrate with your React components. The first time you delete an entire API route file and replace it with three lines of code, you’ll understand why I’m excited about this.
In this guide, I’ll share everything I’ve learned about Server Actions over the past year of building production apps with them. We’ll go from basic form submissions to advanced patterns with Stripe payments, database mutations, and bulletproof error handling. By the end, you’ll understand not just how to use Server Actions, but when to use them instead of API routes—and more importantly, why they’re becoming the default choice for modern Next.js development.
1. What Are Server Actions?
Let’s start with the fundamentals and understand what makes Server Actions special.
1.1 The Traditional API Route Approach
Before Server Actions, here’s how you’d handle a form submission:
export async function POST(request: Request) { const data = await request.json(); // Process data return Response.json({ success: true });}
// app/components/CreatePostForm.tsx'use client';import { useState } from 'react';
export default function CreatePostForm() { const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setLoading(true);
try { const formData = new FormData(e.currentTarget); const response = await fetch('/api/create-post', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: formData.get('title'), content: formData.get('content'), }), });
if (!response.ok) throw new Error('Failed');
// Handle success } catch (error) { // Handle error } finally { setLoading(false); } }
return <form onSubmit={handleSubmit}>...</form>;}Count the steps: Create API route → Setup fetch → Handle FormData → Parse JSON → Manage loading → Handle errors → Update UI. That’s a lot of ceremony!
1.2 The Server Actions Way
Here’s the same functionality with Server Actions:
'use server';
export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string;
// Process data - runs on server // No API route needed!
return { success: true };}
// app/components/CreatePostForm.tsximport { createPost } from '@/app/actions/posts';
export default function CreatePostForm() { return ( <form action={createPost}> <input name="title" /> <textarea name="content" /> <button type="submit">Create Post</button> </form> );}Notice what’s missing: No API route file, no fetch calls, no manual loading state, no JSON serialization, and no event handlers. The action prop directly calls a server function, and Next.js handles everything else automatically.
1.3 How Server Actions Work Under the Hood
When you mark a function with 'use server', Next.js:
- Creates a unique endpoint for that function automatically
- Serializes the function and sends a reference to the client
- Handles the request/response when the function is called
- Manages progressive enhancement - forms work without JavaScript!
- Integrates with React’s Suspense for loading states
It’s like RPC (Remote Procedure Call) built into React. You’re literally calling server functions from the client.
1.4 Key Benefits
Why Server Actions are superior:
- Less Code: No API route boilerplate
- Type Safety: Direct function calls mean TypeScript works perfectly across the client-server boundary
- Progressive Enhancement: Forms work without JS enabled
- Better DX: No context switching between API routes and components
- Automatic Revalidation: Built-in cache invalidation
- Security: Server code never ships to the client
Let’s be honest - most API routes in Next.js apps are just thin wrappers around database calls or external API requests. Server Actions eliminate that wrapper layer entirely.
2. Server Actions Fundamentals
Now that you understand the “why,” let’s master the “how.” I’ll walk you through the patterns I use every day when building with Server Actions.
2.1 Creating Your First Server Action
Server Actions can be defined in two ways:
Option 1: Inline in Server Components
export default function Page() { async function createTodo(formData: FormData) { 'use server';
const title = formData.get('title') as string; // This runs on the server console.log('Server log:', title); }
return ( <form action={createTodo}> <input name="title" /> <button type="submit">Add Todo</button> </form> );}Option 2: Separate Actions File (Recommended)
'use server';
export async function createTodo(formData: FormData) { const title = formData.get('title') as string; // Server-side logic}
export async function deleteTodo(id: string) { // Server-side logic}
// app/page.tsximport { createTodo, deleteTodo } from './actions/todos';
export default function Page() { return <form action={createTodo}>...</form>;}I recommend Option 2 - it’s cleaner, more testable, and you can reuse actions across components. Trust me, when you’re juggling a dozen different actions, having them organized in dedicated files will save your sanity.
2.2 The ‘use server’ Directive
The 'use server' directive tells Next.js: “This code should only run on the server.”
// At the top of the file - all exports are server actions'use server';
export async function action1() { }export async function action2() { }
// Or inline in a functionexport default function Component() { async function inlineAction() { 'use server'; // This specific function is a server action }}Important rules:
- Must be an async function
- Must be in a Server Component file or marked
'use server' - Arguments must be serializable (no functions, classes, or other complex objects)
- Return values must be serializable
2.3 Working with FormData
FormData is the primary input type for Server Actions:
'use server';
export async function createUser(formData: FormData) { // Extract values const name = formData.get('name') as string; const email = formData.get('email') as string; const age = Number(formData.get('age'));
// Multiple values (checkboxes) const interests = formData.getAll('interests') as string[];
// File uploads const avatar = formData.get('avatar') as File;
console.log({ name, email, age, interests, avatar: { name: avatar.name, size: avatar.size, type: avatar.type, } });}Pro tip: For production apps, you’ll want validation on top of this (I’ll show you my Zod validation patterns in section 3.1 and 5.1).
2.4 Progressive Enhancement
One of the coolest features: forms work without JavaScript!
'use server';
import { redirect } from 'next/navigation';
export async function subscribeToNewsletter(formData: FormData) { const email = formData.get('email') as string;
// Save to database await db.newsletter.create({ data: { email } });
// Redirect after success redirect('/thank-you');}If JavaScript is disabled or hasn’t loaded yet, this form still works! Next.js handles the submission as a traditional form POST and executes your Server Action. I can’t tell you how many times this has saved me from angry support tickets about “the form not working” on slow connections.
This is huge for:
- Accessibility
- Slow connections
- JavaScript-disabled browsers
- SEO and crawlers
3. Advanced Patterns and Real-World Examples
Alright, theory is great, but let’s get our hands dirty. Here are the patterns I use most often in production apps—complete with the mistakes I made so you don’t have to.
3.1 Form Submission with Validation
Here’s a complete example with validation using Zod:
'use server';
import { z } from 'zod';import { redirect } from 'next/navigation';
const SignupSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), name: z.string().min(2, 'Name must be at least 2 characters'),});
export async function signup(formData: FormData) { // 1. Validate input const validatedFields = SignupSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), name: formData.get('name'), });
if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, }; }
const { email, password, name } = validatedFields.data;
// 2. Check if user exists const existingUser = await db.user.findUnique({ where: { email }, });
if (existingUser) { return { errors: { email: ['Email already registered'], }, }; }
// 3. Hash password const hashedPassword = await bcrypt.hash(password, 10);
// 4. Create user const user = await db.user.create({ data: { email, password: hashedPassword, name, }, });
// 5. Create session (example with cookies) const session = await createSession(user.id); cookies().set('session', session, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, // 1 week });
// 6. Redirect to dashboard redirect('/dashboard');}Client component with useFormState:
'use client';
import { useFormState } from 'react-dom';import { signup } from '@/app/actions/auth';
const initialState = { errors: {},};
export function SignupForm() { const [state, formAction] = useFormState(signup, initialState);
return ( <form action={formAction} className="space-y-4"> <div> <input type="text" name="name" placeholder="Full Name" className="w-full px-4 py-2 border rounded" /> {state?.errors?.name && ( <p className="text-red-500 text-sm mt-1">{state.errors.name[0]}</p> )} </div>
<div> <input type="email" name="email" placeholder="Email" className="w-full px-4 py-2 border rounded" /> {state?.errors?.email && ( <p className="text-red-500 text-sm mt-1">{state.errors.email[0]}</p> )} </div>
<div> <input type="password" name="password" placeholder="Password" className="w-full px-4 py-2 border rounded" /> {state?.errors?.password && ( <p className="text-red-500 text-sm mt-1">{state.errors.password[0]}</p> )} </div>
<button type="submit" className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600" > Sign Up </button> </form> );}Notice:
useFormStatemanages form state and errors- Validation errors are displayed inline
- No manual loading state needed—React handles it through the form submission
This pattern has saved me countless hours debugging form states in production.
3.2 Calling Server Actions Programmatically
You’re not limited to forms - call Server Actions from event handlers too:
'use server';
export async function likePost(postId: string) { await db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } }, });
revalidatePath('/posts'); return { success: true };}
// app/components/LikeButton.tsx'use client';
import { useState, useTransition } from 'react';import { likePost } from '@/app/actions/posts';
export function LikeButton({ postId }: { postId: string }) { const [isPending, startTransition] = useTransition(); const [likes, setLikes] = useState(0);
const handleLike = () => { startTransition(async () => { const result = await likePost(postId); if (result.success) { setLikes(prev => prev + 1); } }); };
return ( <button onClick={handleLike} disabled={isPending} className="flex items-center gap-2" > ❤️ {likes} {isPending && <span className="animate-spin">⏳</span>} </button> );}Key points:
useTransitionprovides loading state for non-form actionsstartTransitionwraps the async call to enable the loading stateisPendingtells you when the action is running
3.3 Stripe Payment Integration
Here’s where Server Actions really shine - handling payments securely:
'use server';
import Stripe from 'stripe';import { auth } from '@/lib/auth';import { redirect } from 'next/navigation';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(priceId: string) { // 1. Authenticate user const session = await auth();
if (!session?.user) { throw new Error('Unauthorized'); }
// 2. Create Stripe checkout session const checkoutSession = await stripe.checkout.sessions.create({ customer_email: session.user.email, line_items: [ { price: priceId, quantity: 1, }, ], mode: 'subscription', success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, metadata: { userId: session.user.id, }, });
// 3. Redirect to Stripe Checkout redirect(checkoutSession.url!);}
// app/components/PricingCard.tsx'use client';
import { createCheckoutSession } from '@/app/actions/stripe';import { useTransition } from 'react';
export function PricingCard({ priceId }: { priceId: string }) { const [isPending, startTransition] = useTransition();
const handleSubscribe = () => { startTransition(async () => { await createCheckoutSession(priceId); }); };
return ( <div className="border rounded-lg p-6"> <h3 className="text-2xl font-bold">Pro Plan</h3> <p className="text-gray-600">$29/month</p>
<button onClick={handleSubscribe} disabled={isPending} className="w-full bg-blue-500 text-white py-2 rounded mt-4 disabled:opacity-50" > {isPending ? 'Loading...' : 'Subscribe Now'} </button> </div> );}Compare this to the traditional approach:
- No
/api/checkoutroute needed - No fetch call with manual error handling
- Stripe secret key never exposed to client
- Direct redirect after checkout creation
- Clean, readable code that does exactly what it says
I migrated an entire SaaS billing system from API routes to this pattern, and deleted over 500 lines of boilerplate in the process.
3.4 File Uploads
Server Actions handle file uploads elegantly:
'use server';
import { put } from '@vercel/blob';import { revalidatePath } from 'next/cache';
export async function uploadAvatar(formData: FormData) { const file = formData.get('avatar') as File;
if (!file) { return { error: 'No file provided' }; }
// Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return { error: 'Invalid file type' }; }
// Validate file size (5MB) if (file.size > 5 * 1024 * 1024) { return { error: 'File too large (max 5MB)' }; }
try { // Upload to Vercel Blob (or S3, Cloudinary, etc.) const blob = await put(file.name, file, { access: 'public', });
// Update user avatar in database await db.user.update({ where: { id: userId }, data: { avatar: blob.url }, });
revalidatePath('/profile');
return { success: true, url: blob.url }; } catch (error) { return { error: 'Upload failed' }; }}
// app/components/AvatarUpload.tsx'use client';
import { uploadAvatar } from '@/app/actions/uploads';import { useFormState } from 'react-dom';
export function AvatarUpload() { const [state, formAction] = useFormState(uploadAvatar, null);
return ( <form action={formAction}> <input type="file" name="avatar" accept="image/*" className="mb-2" />
{state?.error && ( <p className="text-red-500">{state.error}</p> )}
{state?.success && ( <p className="text-green-500">Upload successful!</p> )}
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded" > Upload Avatar </button> </form> );}4. Data Revalidation and Caching
One of the most powerful—and honestly, most satisfying—features of Server Actions is built-in cache invalidation. No more stale UIs, no more manual cache busting. Let me show you the patterns that have made my life dramatically easier.
4.1 Using revalidatePath
When you mutate data, you need to refresh the UI. Server Actions make this trivial:
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string;
await db.post.create({ data: { title, content }, });
// Revalidate the posts page to show the new post revalidatePath('/posts');
// You can also revalidate specific layouts revalidatePath('/posts', 'layout');}
export async function deletePost(id: string) { await db.post.delete({ where: { id }, });
// Revalidate multiple paths if your data appears in different places revalidatePath('/posts'); revalidatePath('/dashboard');}4.2 Using revalidateTag
For more granular cache control:
export default async function PostsPage() { const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } }).then(r => r.json());
return <div>...</div>;}
// app/actions/posts.ts'use server';
import { revalidateTag } from 'next/cache';
export async function refreshPosts() { // Revalidate all data tagged with 'posts' revalidateTag('posts');}4.3 Optimistic Updates
For instant UI feedback:
'use client';
import { useOptimistic } from 'react';import { toggleTodo } from '@/app/actions/todos';
export function TodoList({ todos }: { todos: Todo[] }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo: Todo) => { // Update immediately in UI return state.map(todo => todo.id === newTodo.id ? newTodo : todo ); } );
const handleToggle = async (todo: Todo) => { // Update UI immediately addOptimisticTodo({ ...todo, completed: !todo.completed });
// Then send to server await toggleTodo(todo.id); };
return ( <ul> {optimisticTodos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => handleToggle(todo)} /> {todo.title} </li> ))} </ul> );}5. Error Handling and Validation
Let’s talk about the stuff that keeps you up at night: what happens when things go wrong? Early in my Server Actions journey, I shipped code that silently failed, and learned this lesson the hard way. Here’s how to build Server Actions that fail gracefully and give users (and yourself) clear feedback.
5.1 Returning Errors from Server Actions
'use server';
import { z } from 'zod';
const PostSchema = z.object({ title: z.string().min(1, 'Title is required').max(100, 'Title too long'), content: z.string().min(10, 'Content must be at least 10 characters'),});
export async function createPost(formData: FormData) { // Validate const validatedFields = PostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), });
if (!validatedFields.success) { return { success: false, errors: validatedFields.error.flatten().fieldErrors, message: 'Validation failed', }; }
try { const post = await db.post.create({ data: validatedFields.data, });
revalidatePath('/posts');
return { success: true, data: post, message: 'Post created successfully', }; } catch (error) { return { success: false, message: 'Database error occurred', }; }}5.2 Using Error Boundaries
For unhandled errors:
'use client';
export default function Error({ error, reset,}: { error: Error & { digest?: string }; reset: () => void;}) { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2> <p className="text-gray-600 mb-4">{error.message}</p> <button onClick={reset} className="bg-blue-500 text-white px-4 py-2 rounded" > Try again </button> </div> );}5.3 Type-Safe Error Handling
Create a helper for consistent error handling:
type ActionResponse<T> = | { success: true; data: T } | { success: false; error: string };
export async function actionWrapper<T>( action: () => Promise<T>): Promise<ActionResponse<T>> { try { const data = await action(); return { success: true, data }; } catch (error) { console.error('Action error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; }}
// app/actions/example.ts'use server';
import { actionWrapper } from '@/lib/action-response';
export async function deletePost(id: string) { return actionWrapper(async () => { await db.post.delete({ where: { id } }); revalidatePath('/posts'); return { id }; });}
// Usage in componentconst result = await deletePost(postId);if (result.success) { console.log('Deleted post:', result.data.id);} else { console.error('Error:', result.error);}6. Security Best Practices
Server Actions run on the server, but security still matters.
6.1 Always Validate Input
Never trust client data:
'use server';
import { z } from 'zod';
export async function updateUserProfile(formData: FormData) { // BAD - No validation const age = Number(formData.get('age')); await db.user.update({ data: { age } }); // What if age is -999?
// GOOD - With validation const schema = z.object({ age: z.number().min(13).max(120), bio: z.string().max(500), });
const validated = schema.parse({ age: Number(formData.get('age')), bio: formData.get('bio'), });
await db.user.update({ data: validated });}6.2 Authentication and Authorization
Always check user permissions:
'use server';
import { auth } from '@/lib/auth';
export async function deletePost(postId: string) { // 1. Authenticate const session = await auth();
if (!session?.user) { throw new Error('Unauthorized'); }
// 2. Authorize const post = await db.post.findUnique({ where: { id: postId }, });
if (post.authorId !== session.user.id) { throw new Error('Forbidden: You can only delete your own posts'); }
// 3. Perform action await db.post.delete({ where: { id: postId }, });
revalidatePath('/posts');}6.3 Rate Limiting
Protect against abuse:
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'),});
export async function checkRateLimit(identifier: string) { const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
if (!success) { throw new Error('Rate limit exceeded. Try again later.'); }
return { limit, reset, remaining };}
// app/actions/contact.ts'use server';
import { checkRateLimit } from '@/lib/rate-limit';
export async function sendContactForm(formData: FormData) { const email = formData.get('email') as string;
// Rate limit by email await checkRateLimit(email);
// Process form await sendEmail({ from: email, subject: 'Contact Form', message: formData.get('message') as string, });}6.4 Preventing CSRF
Server Actions have built-in CSRF protection, but you can add extra security:
'use server';
import { cookies } from 'next/headers';import { verify } from 'jsonwebtoken';
export async function sensitiveAction(formData: FormData) { // Verify CSRF token const csrfToken = formData.get('csrf_token') as string; const storedToken = cookies().get('csrf_token')?.value;
if (csrfToken !== storedToken) { throw new Error('Invalid CSRF token'); }
// Proceed with action}7. Server Actions vs API Routes vs tRPC
Let’s address the elephant in the room: when should you use each approach? I’ve built apps with all three, and here’s what I’ve learned from real-world experience.
7.1 Comparison Table
| Feature | Server Actions | API Routes | tRPC |
|---|---|---|---|
| Boilerplate | Minimal | Medium | Medium |
| Type Safety | Good | Manual | Excellent |
| Bundle Size | Smallest | Medium | Larger |
| Learning Curve | Easy | Easy | Medium |
| External Access | No | Yes | No |
| Progressive Enhancement | Yes | No | No |
| Real-time | No | Yes (with WebSocket) | Yes (subscriptions) |
| File Uploads | Native | Manual | Complex |
| Caching | Built-in | Manual | Smart |
7.2 When to Use Server Actions
✅ Use Server Actions for:
- Form submissions
- Data mutations (CRUD operations)
- File uploads
- Stripe/payment integrations
- Internal app logic
- Simple API calls to external services
Example: User signup, profile updates, creating posts, liking content
7.3 When to Use API Routes
✅ Use API Routes for:
- Webhooks (Stripe, GitHub, etc.)
- Public APIs for external consumption
- OAuth callbacks
- Complex middleware chains
- Custom response headers/status codes
- Real-time with WebSockets
Example: Stripe webhooks, public REST API, OAuth redirect handler
7.4 When to Use tRPC
✅ Use tRPC for:
- Complex apps with many endpoints
- Full-stack TypeScript projects
- Real-time subscriptions
- Advanced middleware patterns
- When you need the best type safety
Example: Large SaaS dashboard, admin panels, complex B2B apps
7.5 My Recommendation
For most Next.js apps, here’s the stack I use:
90% - Server Actions (forms, mutations, uploads)8% - API Routes (webhooks, public APIs)2% - tRPC (if you need subscriptions or have 50+ endpoints)Start with Server Actions. Only add API routes or tRPC when you have a specific need.
8. Common Pitfalls and How to Avoid Them
Let me save you from the mistakes I made learning Server Actions. These are the “gotchas” that cost me hours of debugging—learn from my pain!
8.1 Pitfall: Passing Non-Serializable Data
The mistake I made: In my first Server Action, I tried to be clever and pass a callback function. Next.js promptly reminded me that the client and server don’t share memory.
❌ This breaks:
'use server';
export async function badAction(callback: () => void) { // ERROR: Functions can't be serialized! callback();}✅ This works:
'use server';
export async function goodAction(userId: string) { // Primitive types only console.log(userId);}8.2 Pitfall: The “Why Isn’t My UI Updating?” Moment
We’ve all been there. You submit a form, the database updates, but the list on the page is stale. You refresh, and magically, your new item appears. The most common reason when learning Server Actions is forgetting to revalidate the cache.
❌ Stale UI:
'use server';
export async function createPost(formData: FormData) { await db.post.create({ data: { ... } }); // Forgot revalidatePath!}// User won't see the new post without refresh✅ Fresh UI:
'use server';
export async function createPost(formData: FormData) { await db.post.create({ data: { ... } }); revalidatePath('/posts'); // ✓ UI updates automatically}I spent an embarrassing amount of time debugging this before I realized the fix was literally one line. Now it’s muscle memory: mutate data → revalidate path.
8.3 Pitfall: Silent Failures Are the Worst
'use server';
export async function deletePost(id: string) { await db.post.delete({ where: { id } }); // What if the post doesn't exist? No error returned! // User clicks delete, nothing happens, confusion ensues.}✅ Proper error handling:
'use server';
export async function deletePost(id: string) { try { await db.post.delete({ where: { id } }); return { success: true }; } catch (error) { return { success: false, error: 'Post not found or already deleted', }; }}8.4 Pitfall: Exposing Sensitive Data
❌ Leaking secrets:
'use server';
export async function getApiKey() { // DON'T return sensitive data! return process.env.STRIPE_SECRET_KEY;}✅ Keep secrets on server:
'use server';
export async function createPayment() { // Use secrets internally only const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // Don't return the key return { success: true };}9. Real-World Example: Building a Blog CMS
Let’s put it all together with a complete example.
9.1 Post Creation
'use server';
import { z } from 'zod';import { auth } from '@/lib/auth';import { redirect } from 'next/navigation';import { revalidatePath } from 'next/cache';
const PostSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(10), published: z.boolean(),});
export async function createPost(formData: FormData) { const session = await auth(); if (!session) throw new Error('Unauthorized');
const validated = PostSchema.parse({ title: formData.get('title'), content: formData.get('content'), published: formData.get('published') === 'on', });
const post = await db.post.create({ data: { ...validated, authorId: session.user.id, }, });
revalidatePath('/dashboard/posts'); redirect(`/dashboard/posts/${post.id}`);}
export async function updatePost(id: string, formData: FormData) { const session = await auth(); if (!session) throw new Error('Unauthorized');
// Check ownership const post = await db.post.findUnique({ where: { id } }); if (post.authorId !== session.user.id) { throw new Error('Forbidden'); }
const validated = PostSchema.parse({ title: formData.get('title'), content: formData.get('content'), published: formData.get('published') === 'on', });
await db.post.update({ where: { id }, data: validated, });
revalidatePath('/dashboard/posts'); revalidatePath(`/posts/${id}`);
return { success: true };}
export async function deletePost(id: string) { const session = await auth(); if (!session) throw new Error('Unauthorized');
const post = await db.post.findUnique({ where: { id } }); if (post.authorId !== session.user.id) { throw new Error('Forbidden'); }
await db.post.delete({ where: { id } }); revalidatePath('/dashboard/posts'); redirect('/dashboard/posts');}9.2 Post Editor Component
import { PostEditor } from '@/components/PostEditor';import { db } from '@/lib/db';
export default async function EditPostPage({ params,}: { params: { id: string };}) { const post = await db.post.findUnique({ where: { id: params.id }, });
if (!post) { notFound(); }
return <PostEditor post={post} />;}
// components/PostEditor.tsx'use client';
import { updatePost } from '@/app/actions/posts';import { useFormState } from 'react-dom';import { useFormStatus } from 'react-dom';
function SubmitButton() { const { pending } = useFormStatus();
return ( <button type="submit" disabled={pending} className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50" > {pending ? 'Saving...' : 'Save Post'} </button> );}
export function PostEditor({ post }: { post: Post }) { const updatePostWithId = updatePost.bind(null, post.id); const [state, formAction] = useFormState(updatePostWithId, null);
return ( <form action={formAction} className="space-y-4"> <div> <label className="block mb-2">Title</label> <input name="title" defaultValue={post.title} className="w-full px-4 py-2 border rounded" /> </div>
<div> <label className="block mb-2">Content</label> <textarea name="content" defaultValue={post.content} rows={10} className="w-full px-4 py-2 border rounded" /> </div>
<div> <label className="flex items-center gap-2"> <input type="checkbox" name="published" defaultChecked={post.published} /> <span>Publish post</span> </label> </div>
{state?.success && ( <p className="text-green-500">Post saved successfully!</p> )}
<SubmitButton /> </form> );}10. Conclusion
Server Actions represent a fundamental shift in how we build Next.js applications. They’re not just a new API—they’re a better way to think about client-server interactions. And honestly? Once you start using them, going back to API routes for simple mutations feels like taking the long way home.
What we covered:
- Server Actions eliminate API route boilerplate - Direct function calls replace fetch requests and all the ceremony that comes with them
- Progressive enhancement works out of the box - Your forms work without JavaScript, which is pretty incredible when you think about it
- Type safety is automatic - TypeScript flows through the entire stack without manual type definitions
- Cache invalidation is built-in -
revalidatePathandrevalidateTagsolve the stale UI problem with one line of code - Security is improved - Server code never ships to the client, and you control exactly what data flows where
- Real-world use cases - From authentication to payments to file uploads, Server Actions handle the hard stuff elegantly
My honest take after a year of production use:
Server Actions won’t replace API routes completely—you’ll still need them for webhooks, public APIs, and OAuth callbacks. But for internal app logic—forms, mutations, CRUD operations, file uploads—Server Actions are superior in every way that matters for day-to-day development.
They make your code:
- Shorter (I’ve deleted thousands of lines of boilerplate)
- Safer (server-only execution with built-in CSRF protection)
- Faster to write (no context switching between client and server files)
- Simpler to reason about (progressive enhancement just works)
The first time I shipped a feature using Server Actions, I kept looking at the code thinking “this can’t be all of it.” But it was. Three files instead of seven. Fifty lines instead of two hundred. And it just worked.
Start using Server Actions today. Begin with your next form, your next CRUD operation, your next file upload. You don’t need to refactor your entire app—just start with one feature and feel the difference. Your future self (and your teammates reviewing PRs) will thank you for the cleaner, more maintainable codebase.
What’s Next?
Ready to level up further? Here are the topics I recommend exploring next:
- Server Components - The foundation that makes Server Actions possible (they work beautifully together)
- Streaming and Suspense - Advanced loading patterns that make your app feel instant
- Partial Prerendering - Combining static and dynamic content for the best of both worlds
- React 19 Features -
useFormState,useFormStatus,useOptimistic, and the hooks that make Server Actions shine - Advanced Caching Strategies - Fine-grained cache control for complex applications
Happy coding, and may your forms submit instantly and your caches invalidate exactly when they should! 🚀
Ready to Build with LLMs?
The concepts in this post are just the start. My free 11-page cheat sheet gives you copy-paste prompts and patterns to get reliable, structured output from any model.
Related Articles
Authentication in Next.js 14 with NextAuth.js
Learn about Authentication in Next.js 14 with NextAuth.js
Building a Shopping Cart with React and Local Storage
Learn about Building a Shopping Cart with React and Local Storage
Stripe + Next.js 15: The Complete 2025 Guide
Build production-ready payment systems with Stripe and Next.js 15 using Server Actions - from one-time payments to subscriptions and credit systems
Smart Login in Next.js with NextAuth.js
Learn about Smart Login in Next.js with NextAuth.js