Skip to main content
Ganesh Joshi
Back to Blogs

Next.js Server Actions: forms and mutations without API routes

February 15, 20266 min read
Tutorials
Laptop with code on screen, form or server code

Server Actions in Next.js let you run server-side code directly from forms and client components without writing separate API routes. You define a function with 'use server', and Next.js handles the network layer. This simplifies form handling, mutations, and any operation that needs server-side logic.

What are Server Actions?

A Server Action is an async function marked with 'use server'. When called from the client, Next.js:

  1. Serializes the arguments
  2. Sends a POST request to the server
  3. Executes the function on the server
  4. Returns the result to the client
Traditional approach Server Actions
Create API route in pages/api or app/api Write function with 'use server'
Write fetch call in client Pass function to form action or call directly
Parse request body Receive typed arguments
Return JSON response Return values or throw errors

Server Actions reduce boilerplate and keep related code together.

Creating a Server Action

Mark a file or function with 'use server':

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

export async function submitContact(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Validate
  if (!name || !email || !message) {
    return { error: 'All fields are required' };
  }

  // Save to database, send email, etc.
  await db.insert(contacts).values({ name, email, message });

  return { success: true };
}

The function runs only on the server. You can use environment variables, database clients, and server-only APIs safely.

Using in a form

Pass the action to the form's action prop:

// app/contact/page.tsx
import { submitContact } from '../actions';

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <label>
        Name
        <input name="name" required />
      </label>
      <label>
        Email
        <input name="email" type="email" required />
      </label>
      <label>
        Message
        <textarea name="message" required />
      </label>
      <button type="submit">Send</button>
    </form>
  );
}

The form submits to the current URL via POST. Next.js intercepts it, calls the Server Action, and handles the response. No JavaScript required for basic functionality—forms work without JS enabled.

Handling loading states

Use useFormStatus in a component inside the form:

'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Sending...' : 'Send'}
    </button>
  );
}

Use it in the form:

import { submitContact } from '../actions';
import { SubmitButton } from './SubmitButton';

export default function ContactPage() {
  return (
    <form action={submitContact}>
      {/* inputs */}
      <SubmitButton />
    </form>
  );
}

The button shows "Sending..." and disables while the action runs.

Reading action results

Use useActionState to access the action's return value:

'use client';

import { useActionState } from 'react';
import { submitContact } from '../actions';

const initialState = { error: null, success: false };

export default function ContactForm() {
  const [state, formAction, pending] = useActionState(
    submitContact,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />

      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      {state.success && (
        <p className="text-green-500">Message sent!</p>
      )}

      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

The action must return a new state object. The component re-renders with the result.

Validation with Zod

Use a schema library for type-safe validation:

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

import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

export async function submitContact(prevState: any, formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = ContactSchema.safeParse(rawData);

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      success: false,
    };
  }

  await db.insert(contacts).values(result.data);

  return { errors: null, success: true };
}

Display field-level errors:

{state.errors?.name && (
  <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
)}

Calling actions from event handlers

You can call Server Actions from any client code, not just forms:

'use client';

import { deleteItem } from '../actions';

export function DeleteButton({ id }: { id: string }) {
  async function handleClick() {
    const result = await deleteItem(id);
    if (result.error) {
      alert(result.error);
    }
  }

  return (
    <button onClick={handleClick}>
      Delete
    </button>
  );
}

The action:

'use server';

export async function deleteItem(id: string) {
  const session = await getSession();
  if (!session) {
    return { error: 'Unauthorized' };
  }

  await db.delete(items).where(eq(items.id, id));
  revalidatePath('/items');

  return { success: true };
}

Revalidating data

After mutations, revalidate cached data:

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.insert(posts).values({ /* ... */ });

  // Revalidate specific path
  revalidatePath('/posts');

  // Or revalidate by tag
  revalidateTag('posts');
}

The next request to that path or tag fetches fresh data.

Redirecting after success

Redirect to a new page after successful mutation:

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const post = await db.insert(posts).values({ /* ... */ }).returning();

  redirect(`/posts/${post.id}`);
}

Call redirect after all server work is complete. It throws internally to trigger navigation.

Optimistic updates

Update UI immediately while the action runs:

'use client';

import { useOptimistic } from 'react';
import { likePost } from '../actions';

export function LikeButton({ postId, initialLikes }: Props) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current) => current + 1
  );

  async function handleLike() {
    addOptimisticLike(null);
    await likePost(postId);
  }

  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  );
}

The count increases immediately. If the action fails, React reverts the optimistic update.

Security considerations

Server Actions are secure by default, but follow these practices:

Practice Reason
Always validate inputs Users can call actions with any data
Check authentication Verify session before mutations
Use CSRF protection Next.js includes this automatically
Rate limit sensitive actions Prevent abuse
Don't trust client data Re-fetch data you need server-side
export async function deleteAccount() {
  const session = await getSession();
  if (!session) {
    throw new Error('Unauthorized');
  }

  // Re-fetch user instead of trusting client-sent ID
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.userId),
  });

  await db.delete(users).where(eq(users.id, user.id));
}

Error handling patterns

For expected errors, return error objects:

export async function updateProfile(formData: FormData) {
  try {
    await db.update(users).set({ /* ... */ });
    return { success: true };
  } catch (error) {
    return { error: 'Failed to update profile' };
  }
}

For unexpected errors, let them throw. Catch with error boundaries:

// app/profile/error.tsx
'use client';

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Summary

Server Actions simplify form handling and mutations in Next.js. Mark functions with 'use server', pass them to form actions, and use hooks like useFormStatus and useActionState for loading states and results. Validate with Zod, revalidate cached data after mutations, and use optimistic updates for responsive UIs. Server Actions run only on the server, keeping your logic secure while reducing boilerplate.

Frequently Asked Questions

Server Actions are async functions that run on the server and can be called directly from client components. They replace the need for API routes for form submissions and data mutations.

Add 'use server' at the top of a file or function. Export async functions that accept FormData or other serializable arguments. Call them from form action props or client event handlers.

Yes. Server Actions run only on the server. Client code never sees the implementation. Next.js creates a secure POST endpoint automatically. Always validate inputs since users can call actions with any data.

Use the useFormStatus hook in a child component of the form to get the pending state. Show a spinner or disable the submit button while pending is true.

Return an object with error fields from the action. Use useActionState to read the return value and display errors. For unexpected errors, let them throw and catch with error boundaries.

Related Posts