← Back to list

Next.js 15 Server Actions: Complete Guide with Real Examples (2026)

Server Actions changed everything about how I build Next.js applications. No more API routes for simple data mutations. No more boilerplate…

Saad Minhas · 2026-01-03 10:16 · 2 claps · 12.1 min read
#nextjs #server-action #web-development #nextjs-server-action
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Next.js 15 Server Actions: Complete Guide with Real Examples (2026)

Server Actions changed everything about how I build Next.js applications. No more API routes for simple data mutations. No more boilerplate for form handling. Just write a function, add 'use server', and call it from your components.

But here’s what nobody tells you: Server Actions are deceptively simple on the surface, yet filled with gotchas that can break your app in production. I’ve spent the past year building applications with Server Actions at Appzivo, and I’ve learned these lessons the hard way — through bugs, performance issues, and late-night debugging sessions.

This isn’t another basic tutorial that shows you how to create a todo app. This is the comprehensive guide I wish I had when I started — covering real-world patterns, common mistakes, security considerations, and production-ready code you can actually use.

What Are Server Actions (And Why They Matter)

Server Actions are server-side functions you can call directly from client components. That might sound simple, but it fundamentally changes how you build full-stack Next.js applications.

Before Server Actions, here’s what a typical form submission looked like:

The Old Way (API Routes):

// pages/api/users.ts
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const { name, email } = req.body;
  // Validation logic
  // Database mutation
  // Error handling
  res.status(200).json({ success: true });
}
// components/UserForm.tsx
'use client'
export function UserForm() {
  const handleSubmit = async (e) => {
    e.preventDefault();
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, email })
    });
    // Handle response
  }
  return <form onSubmit={handleSubmit}>...</form>
}

That’s a lot of boilerplate for something simple. Now look at the Server Actions approach:

The New Way (Server Actions):

// app/actions.ts
'use server'
export async function createUser(formData: FormData) {
  const name = formData.get('name');
  const email = formData.get('email');
  // Validation and database mutation
  revalidatePath('/users');
}
// components/UserForm.tsx
import { createUser } from '@/app/actions';
export function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <input name="email" />
      <button type="submit">Create</button>
    </form>
  );
}

Less code. Better developer experience. Automatic progressive enhancement — forms work even before JavaScript loads.

Understanding the ‘use server’ Directive

The 'use server' directive is how you tell Next.js that a function should execute on the server. But there are two ways to use it, and understanding the difference is crucial.

Method 1: File-Level Directive

Place 'use server' at the top of a file, and every exported function becomes a Server Action:

'use server'
export async function createPost(formData: FormData) {
  // This is a Server Action
}
export async function deletePost(id: string) {
  // This is also a Server Action
}
export async function updatePost(id: string, data: any) {
  // This too
}

This is the cleanest approach when you have multiple related Server Actions. I typically create an actions.ts file for each feature area.

Method 2: Function-Level Directive

Place 'use server' inside a specific function:

export async function MyComponent() {
  async function handleSubmit(formData: FormData) {
    'use server'
    // Only this function is a Server Action
  }

  return <form action={handleSubmit}>...</form>
}

Use this when you need a Server Action that’s tightly coupled to a specific component and won’t be reused elsewhere.

Important Security Note: Even unused Server Actions expose public HTTP endpoints. In Next.js 15, unused actions are automatically removed during build, but you should still be mindful about what you export.

Working with FormData: The Foundation

Server Actions shine when handling forms. When you pass a Server Action to the action prop, Next.js automatically provides a FormData object:

'use server'
export async function createInvoice(formData: FormData) {
  // Extract individual fields
  const customerId = formData.get('customerId') as string;
  const amount = formData.get('amount') as string;
  const status = formData.get('status') as string;

  // Or convert entire form to object
  const rawFormData = Object.fromEntries(formData);

  console.log(rawFormData);
  // { customerId: '123', amount: '1000', status: 'pending' }

  // Mutate database
  await db.invoices.create({
    customerId,
    amount: parseFloat(amount),
    status
  });

  revalidatePath('/invoices');
}
export function InvoiceForm() {
  return (
    <form action={createInvoice}>
      <select name="customerId">
        <option value="123">Customer A</option>
        <option value="456">Customer B</option>
      </select>

      <input 
        type="number" 
        name="amount" 
        placeholder="Amount" 
        required 
      />

      <select name="status">
        <option value="pending">Pending</option>
        <option value="paid">Paid</option>
      </select>

      <button type="submit">Create Invoice</button>
    </form>
  );
}

FormData works seamlessly with standard HTML inputs, but you need to be careful with:

  • Checkboxes (use .getAll() for multiple values)
  • File uploads (files become File objects)
  • Hidden fields (useful for passing non-user-visible data)

Calling Server Actions Outside Forms

Server Actions aren’t limited to forms. You can invoke them from event handlers, useEffect, or anywhere you can call an async function:

'use client'
import { incrementLikes } from '@/app/actions';
import { useState } from 'react';
export function LikeButton({ postId, initialLikes }: Props) {
  const [likes, setLikes] = useState(initialLikes);
  const [isLiking, setIsLiking] = useState(false);
  async function handleLike() {
    setIsLiking(true);
    try {
      const newLikes = await incrementLikes(postId);
      setLikes(newLikes);
    } catch (error) {
      console.error('Failed to like post:', error);
    } finally {
      setIsLiking(false);
    }
  }
  return (
    <button 
      onClick={handleLike}
      disabled={isLiking}
    >
      {isLiking ? '...' : `❤️ ${likes}`}
    </button>
  );
}

The Server Action:

'use server'
export async function incrementLikes(postId: string) {
  const post = await db.posts.update({
    where: { id: postId },
    data: { likes: { increment: 1 } }
  });

  revalidatePath(`/posts/${postId}`);
  return post.likes;
}

This pattern is perfect for:

  • Like/favorite buttons
  • Real-time updates
  • Background syncing
  • Polling for data changes

Error Handling: The Right Way

Error handling in Server Actions is where most developers stumble. The key insight: don’t throw errors for expected validation failures. Throwing errors triggers React Error Boundaries, which is terrible UX for simple validation mistakes.

Bad Pattern (Don’t Do This):

'use server'
export async function createUser(formData: FormData) {
  const email = formData.get('email');

  if (!email) {
    throw new Error('Email is required');  // ❌ Bad!
  }

  // This triggers error.tsx, showing a full error page
  // User loses all form data
}

Good Pattern (Return Error States):

'use server'
type ActionResult = {
  success: boolean;
  error?: string;
  user?: User;
}
export async function createUser(
  prevState: ActionResult, 
  formData: FormData
): Promise<ActionResult> {
  const email = formData.get('email') as string;

  // Validation
  if (!email) {
    return { 
      success: false, 
      error: 'Email is required' 
    };
  }

  if (!email.includes('@')) {
    return { 
      success: false, 
      error: 'Invalid email format' 
    };
  }

  try {
    const user = await db.users.create({ email });
    revalidatePath('/users');

    return { 
      success: true, 
      user 
    };
  } catch (error) {
    // Database errors or unexpected issues
    return { 
      success: false, 
      error: 'Failed to create user. Please try again.' 
    };
  }
}

Client-side usage with useActionState:

'use client'
import { useActionState } from 'react';
import { createUser } from '@/app/actions';
const initialState = { success: false };
export function UserForm() {
  const [state, formAction, pending] = useActionState(
    createUser, 
    initialState
  );
  return (
    <form action={formAction}>
      <input 
        type="email" 
        name="email" 
        required 
      />

      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      {state.success && (
        <p className="text-green-500">User created!</p>
      )}

      <button disabled={pending}>
        {pending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

The useActionState hook (React 19) gives you:

  • state: The returned value from your Server Action
  • formAction: The wrapped action to pass to your form
  • pending: Boolean indicating if the action is currently executing

Validation with Zod: Production Pattern

For real applications, use a proper validation library. Here’s the pattern I use in production with Zod:

'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const createPostSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  published: z.boolean().default(false),
  tags: z.array(z.string()).optional(),
});
type CreatePostResult = {
  success: boolean;
  errors?: {
    title?: string[];
    content?: string[];
    _form?: string[];
  };
  post?: Post;
}
export async function createPost(
  prevState: CreatePostResult,
  formData: FormData
): Promise<CreatePostResult> {
  // Parse and validate
  const validatedFields = createPostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'on',
  });
  // Return field-specific errors
  if (!validatedFields.success) {
    return {
      success: false,
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
  // Type-safe data
  const { title, content, published } = validatedFields.data;
  try {
    const post = await db.posts.create({
      data: { title, content, published }
    });
    revalidatePath('/posts');

    return { 
      success: true, 
      post 
    };
  } catch (error) {
    return {
      success: false,
      errors: {
        _form: ['Failed to create post. Please try again.']
      }
    };
  }
}

Client component:

'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export function PostForm() {
  const [state, formAction, pending] = useActionState(
    createPost,
    { success: false }
  );
  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          type="text"
          required
        />
        {state.errors?.title && (
          <p className="text-red-500">{state.errors.title[0]}</p>
        )}
      </div>
      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          required
        />
        {state.errors?.content && (
          <p className="text-red-500">{state.errors.content[0]}</p>
        )}
      </div>
      <div>
        <label>
          <input type="checkbox" name="published" />
          Publish immediately
        </label>
      </div>
      {state.errors?._form && (
        <p className="text-red-500">{state.errors._form[0]}</p>
      )}
      {state.success && (
        <p className="text-green-500">Post created successfully!</p>
      )}
      <button type="submit" disabled={pending}>
        {pending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Passing Additional Arguments

Sometimes you need to pass data beyond what’s in the form. Use JavaScript’s bind method:

'use client'
import { updateUser } from '@/app/actions';
export function UserProfile({ userId }: { userId: string }) {
  // Bind userId as first argument
  const updateUserWithId = updateUser.bind(null, userId);
  return (
    <form action={updateUserWithId}>
      <input name="name" type="text" />
      <button type="submit">Update Name</button>
    </form>
  );
}

Server Action:

'use server'
export async function updateUser(
  userId: string,  // From bind
  formData: FormData  // From form
) {
  const name = formData.get('name') as string;

  await db.users.update({
    where: { id: userId },
    data: { name }
  });

  revalidatePath(`/users/${userId}`);
}

This pattern is essential when you need context (user ID, post ID, etc.) that isn’t part of the form data itself.

Loading States and Optimistic Updates

Users need feedback. Here’s how to provide it properly.

Basic Loading State:

'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export function PostForm() {
  const [state, formAction, pending] = useActionState(
    createPost,
    { success: false }
  );
  return (
    <form action={formAction}>
      <input name="title" disabled={pending} />
      <textarea name="content" disabled={pending} />

      <button disabled={pending}>
        {pending ? (
          <>
            <Spinner /> Creating...
          </>
        ) : (
          'Create Post'
        )}
      </button>
    </form>
  );
}

Optimistic Updates:

For better perceived performance, update the UI immediately before the server responds:

'use client'
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';
export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: string) => [
      ...state,
      { id: Date.now(), text: newTodo, completed: false }
    ]
  );
  async function formAction(formData: FormData) {
    const text = formData.get('todo') as string;

    // Optimistically add to UI
    addOptimisticTodo(text);

    // Server mutation happens in background
    await addTodo(formData);
  }
  return (
    <>
      <form action={formAction}>
        <input name="todo" />
        <button>Add</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

The optimistic update appears instantly, while the actual server mutation happens in the background. If the server action fails, React automatically reverts the optimistic update.

Cache Revalidation: Keeping Data Fresh

After mutations, you need to update cached data. Next.js provides two functions:

revalidatePath:

Revalidate specific routes:

'use server'
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
  // Create post...

  // Revalidate the posts list page
  revalidatePath('/posts');

  // Revalidate specific post if updating
  revalidatePath(`/posts/${postId}`);

  // Revalidate layout and all nested pages
  revalidatePath('/dashboard', 'layout');
}

revalidateTag:

For more granular control, use cache tags:

// Fetch with tag
export async function getPosts() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  });
  return posts.json();
}
// Revalidate by tag
'use server'
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
  // Create post...

  // Revalidate all data tagged with 'posts'
  revalidateTag('posts');
}

Use tags when you have related data fetched in multiple places that should all revalidate together.

Common Mistakes (And How to Avoid Them)

After building dozens of applications with Server Actions, I’ve seen these mistakes repeatedly:

1. Not Showing Loading States

Bad:

<button type="submit">Submit</button>

Users click multiple times, causing duplicate submissions.

Good:

const [, , pending] = useActionState(action, initialState);
<button disabled={pending}>
  {pending ? 'Submitting...' : 'Submit'}
</button>

2. Using redirect Inside try/catch

Bad:

try {
  await db.create(data);
  redirect('/success');  // ❌ Gets caught!
} catch (error) {
  return { error: 'Failed' };
}

The redirect function throws an error internally, which your catch block intercepts.

Good:

try {
  await db.create(data);
} catch (error) {
  return { error: 'Failed' };
}
redirect('/success');  // ✅ Outside try/catch

3. Exposing Sensitive Data

Bad:

'use server'
export async function getUser(id: string) {
  const user = await db.users.findUnique({
    where: { id },
    select: {
      email: true,
      password: true,  // ❌ Never expose!
      apiKey: true,    // ❌ Never expose!
    }
  });
  return user;  // Sent to client
}

Good:

'use server'
export async function getUser(id: string) {
  const user = await db.users.findUnique({
    where: { id },
    select: {
      id: true,
      email: true,
      name: true,
      // Only public fields
    }
  });
  return user;
}

4. Not Validating Inputs

Bad:

'use server'
export async function updateSettings(formData: FormData) {
  const value = formData.get('setting');
  await db.settings.update({ value });  // ❌ No validation!
}

Always validate. Always. Users can manipulate form data in DevTools.

Good:

'use server'
const schema = z.object({
  setting: z.enum(['option1', 'option2', 'option3'])
});
export async function updateSettings(formData: FormData) {
  const validated = schema.safeParse({
    setting: formData.get('setting')
  });

  if (!validated.success) {
    return { error: 'Invalid input' };
  }

  await db.settings.update(validated.data);
}

5. Forgetting to Revalidate

Bad:

'use server'
export async function createPost(formData: FormData) {
  await db.posts.create(data);
  // ❌ Cache not revalidated, user sees stale data
}

Good:

'use server'
export async function createPost(formData: FormData) {
  await db.posts.create(data);
  revalidatePath('/posts');  // ✅ Fresh data
}

Security Considerations

Server Actions expose public HTTP endpoints. Treat them with the same security mindset as API routes.

Authentication:

Always verify the user is authenticated:

'use server'
import { auth } from '@/lib/auth';
export async function deletePost(postId: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  // Verify user owns the post
  const post = await db.posts.findUnique({
    where: { id: postId }
  });

  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }

  await db.posts.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

Rate Limiting:

For production apps, implement rate limiting:

'use server'
import { ratelimit } from '@/lib/redis';
export async function sendEmail(formData: FormData) {
  const session = await auth();

  // Allow 5 emails per hour per user
  const { success } = await ratelimit.limit(
    `email:${session.user.id}`
  );

  if (!success) {
    return { error: 'Rate limit exceeded' };
  }

  // Send email...
}

Input Sanitization:

Never trust user input. Sanitize everything:

'use server'
import { sanitize } from 'isomorphic-dompurify';
export async function createComment(formData: FormData) {
  const rawContent = formData.get('content') as string;

  // Strip HTML tags and dangerous content
  const content = sanitize(rawContent);

  await db.comments.create({ content });
}

Testing Server Actions

Server Actions are just async functions, making them straightforward to test:

import { expect, test, vi } from 'vitest';
import { createPost } from './actions';
// Mock database
vi.mock('@/lib/db', () => ({
  db: {
    posts: {
      create: vi.fn()
    }
  }
}));
test('createPost creates a post', async () => {
  const formData = new FormData();
  formData.append('title', 'Test Post');
  formData.append('content', 'Test content');
  const result = await createPost({ success: false }, formData);
  expect(result.success).toBe(true);
  expect(db.posts.create).toHaveBeenCalledWith({
    data: {
      title: 'Test Post',
      content: 'Test content',
      published: false
    }
  });
});
test('createPost returns error for invalid input', async () => {
  const formData = new FormData();
  formData.append('title', 'ab');  // Too short
  const result = await createPost({ success: false }, formData);
  expect(result.success).toBe(false);
  expect(result.errors?.title).toBeDefined();
});

Advanced Pattern: Type-Safe Server Actions

For maximum type safety, use a library like next-safe-action:

'use server'
import { createSafeAction } from 'next-safe-action';
import { z } from 'zod';
const createPostSchema = z.object({
  title: z.string().min(3),
  content: z.string().min(10),
});
export const createPost = createSafeAction(
  createPostSchema,
  async ({ title, content }) => {
    // TypeScript knows title and content are validated strings
    const post = await db.posts.create({
      data: { title, content }
    });

    revalidatePath('/posts');
    return { post };
  }
);

Client usage:

'use client'
import { useAction } from 'next-safe-action/hooks';
import { createPost } from '@/app/actions';
export function PostForm() {
  const { execute, status, result } = useAction(createPost);
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        execute({
          title: formData.get('title') as string,
          content: formData.get('content') as string,
        });
      }}
    >
      <input name="title" />
      <textarea name="content" />

      {result.validationErrors && (
        <p>{JSON.stringify(result.validationErrors)}</p>
      )}

      <button disabled={status === 'executing'}>
        {status === 'executing' ? 'Creating...' : 'Create'}
      </button>
    </form>
  );
}

This provides end-to-end type safety from client to server with automatic validation and error handling.

When NOT to Use Server Actions

Server Actions aren’t always the answer. Avoid them for:

Real-time features: Use WebSockets or Server-Sent Events instead File uploads over 1MB: The default body size limit is 1MB (configurable, but consider direct uploads) Long-running tasks: Actions time out. Use background jobs instead Public APIs: Use API routes for third-party consumption Streaming responses: Server Actions return single values, not streams

Performance Optimization

Debounce Rapid Calls:

'use client'
import { useDebouncedCallback } from 'use-debounce';
import { searchPosts } from '@/app/actions';
export function SearchBar() {
  const handleSearch = useDebouncedCallback(
    async (term: string) => {
      await searchPosts(term);
    },
    300  // Wait 300ms after user stops typing
  );
  return (
    <input
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="Search..."
    />
  );
}

Batch Operations:

Instead of calling an action multiple times, batch operations:

'use server'
// Bad: Multiple calls
export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } });
}
// Good: Batch delete
export async function deletePosts(ids: string[]) {
  await db.posts.deleteMany({
    where: { id: { in: ids } }
  });
  revalidatePath('/posts');
}

The Bottom Line

Server Actions represent a paradigm shift in how we build Next.js applications. They eliminate API route boilerplate, provide automatic progressive enhancement, and integrate seamlessly with React’s concurrent features.

But power comes with responsibility. Validate inputs. Handle errors properly. Implement authentication. Show loading states. Test thoroughly.

The patterns in this guide come from building real applications in production. They’ll save you from the mistakes I made and help you build robust, user-friendly applications.

Start simple. Add a Server Action to handle a form submission. Then gradually adopt the more advanced patterns — validation with Zod, optimistic updates, proper error handling — as your application grows in complexity.

Server Actions aren’t just a new feature. They’re a better way to build full-stack Next.js applications. Master them, and you’ll wonder how you ever built apps without them.

Ready to implement? Start with a simple form mutation today. Apply the error handling pattern. Add proper validation. Your users will thank you, and your codebase will be cleaner for it.


메타데이터
post_id
6320fbfa01c3
slug
next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
url
https://medium.com/@saad.minhas.codes/next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
canonical_url
https://medium.com/@saad.minhas.codes/next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
author_url
https://medium.com/@saad.minhas.codes
status
ok
fetched_at
2026-06-09 15:37:30