Back to blog

Mastering Next.js Server Actions: Secure and High-Performance Data Mutations

Next.js Server Actions are asynchronous server functions that can be called directly from your client or server components. They allow you to handle data mutations, form submissions, and database operations without manually writing API routes. This feature streamlines full-stack development, making it faster and more secure.

In this guide, we will explore the core concepts of Server Actions, learn how to build secure forms, validate inputs using Zod, and leverage modern React hooks to handle loading and error states.

Why Use Server Actions?

Before Server Actions, submitting a form in a React app required:

  1. Creating an API route (e.g., /api/submit).
  2. Setting up a client-side form event handler.
  3. Writing a fetch request to POST data to the endpoint.
  4. Parsing the response on the client side.

Server Actions simplify this lifecycle. You define a function that runs on the server, and you can reference it directly in a form action attribute. Behind the scenes, Next.js manages the HTTP POST request, client-server serialization, and cache revalidation automatically.

Setting Up Your First Server Action

You can define Server Actions in two places: inside a Server Component, or in a separate file marked with a directive.

To keep your code modular and reusable, the best practice is to place actions in dedicated files. Create a file called actions.ts:

// app/actions.ts
'use server';

export async function submitForm(formData: FormData) {
  const email = formData.get('email');
  
  if (!email || typeof email !== 'string') {
    return { success: false, error: 'Email is required' };
  }

  // Perform database insert or send notification here
  console.log('Saving email to database:', email);

  return { success: true };
}

By placing 'use server' at the top of the file, all exported functions are treated as entry points that can be called from client components.

Using Server Actions in a Client Component

To use the action inside a component, import the function and pass it to the action attribute of a form. You can use React's useTransition or useActionState hook to manage pending states.

Here is an example using the modern useActionState hook to manage the form state:

// app/Form.tsx
'use client';

import React from 'react';
import { useActionState } from 'react';
import { submitForm } from './actions';

export function Form() {
  const [state, action, isPending] = useActionState(submitForm, null);

  return (
    <form action={action} className="form-container">
      <label htmlFor="email">Subscribe to our newsletter</label>
      <input
        id="email"
        name="email"
        type="email"
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting...' : 'Subscribe'}
      </button>
      {state && !state.success && (
        <p className="error-message">{state.error}</p>
      )}
      {state && state.success && (
        <p className="success-message">Thank you for subscribing!</p>
      )}
    </form>
  );
}

The useActionState hook takes the server action and an initial state as arguments. It returns the current state, the action dispatcher, and a boolean indicating whether the action is running.

Securing Server Actions

Because Server Actions expose public POST endpoints under the hood, they must be secured just like standard API routes. Never trust incoming client data.

1. Input Validation

Always validate input fields on the server. Libraries like Zod make schema validation straightforward and type-safe.

// app/actions.ts
'use server';

import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Invalid email address'),
});

export async function subscribe(formData: FormData) {
  const rawEmail = formData.get('email');
  const result = schema.safeParse({ email: rawEmail });

  if (!result.success) {
    return {
      success: false,
      error: result.error.flatten().fieldErrors.email?.[0] || 'Validation failed',
    };
  }

  // Save result.data.email to the database
  return { success: true };
}

2. Authentication and Authorization

Always verify the user session inside the server action before mutating data.

// app/actions.ts
'use server';

import { getSession } from './auth';

export async function updateProfile(formData: FormData) {
  const session = await getSession();
  
  if (!session || !session.user) {
    throw new Error('Unauthorized');
  }

  const name = formData.get('name');
  // Update name for session.user.id in the database
  
  return { success: true };
}

Revalidating the Cache

When data changes, you want to update the UI. Next.js provides revalidatePath and revalidateTag to refresh cached data instantly.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function addComment(formData: FormData) {
  const comment = formData.get('comment');
  // Add comment to database
  
  // Revalidate the comments page to show the new comment
  revalidatePath('/blog/my-post');
  
  return { success: true };
}

By calling revalidatePath, Next.js purges the server-side cache for that route, triggering a background fetch to render the updated UI without forcing a full page reload.

Conclusion

Next.js Server Actions represent a shift in how we handle data mutations in full-stack web applications. By eliminating boilerplate API routes, providing built-in pending state hooks, and making security checks straightforward, they improve both developer experience and application performance. Incorporate input validation and session checks into your Server Actions to ensure your backend remains secure and reliable.