DEV Community

Cover image for Next.js Server Actions — Form Handling Without API Routes
Aon infotech
Aon infotech

Posted on

Next.js Server Actions — Form Handling Without API Routes

Server Actions let you run server-side code directly from React components — no separate API route file, no fetch call, no request/response boilerplate. A form submission can call a server function directly, and the result updates the UI. This is the pattern that replaces the classic POST /api/submit for most form handling.

Here's the complete pattern, including progressive enhancement, error handling, and the loading state management that makes forms feel responsive — the approach used for content management on website image generation patterns.

Defining a Server Action

Server Actions are async functions marked with 'use server'. They can be defined in a separate file or inline in a Server Component.

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

import { revalidatePath } from 'next/cache';

type ContactFormData = {
  name: string;
  email: string;
  message: string;
};

type ActionResult = {
  success: boolean;
  error?: string;
};

export async function submitContact(
  formData: FormData
): Promise<ActionResult> {
  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 { success: false, error: 'All fields are required' };
  }

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

  try {
    await saveToDatabase({ name, email, message });
    revalidatePath('/contact');
    return { success: true };
  } catch (error) {
    return { success: false, error: 'Submission failed. Please try again.' };
  }
}
Enter fullscreen mode Exit fullscreen mode

The 'use server' directive marks this as a Server Action — Next.js handles the network boundary automatically. The function receives FormData and returns a result.

Basic Form With Server Action

The simplest usage: attach the action directly to a form's action prop.

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

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>
      <button type="submit">Send Message</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works — and it works without JavaScript enabled, because it's a standard HTML form submission. This is progressive enhancement built in.

Adding Loading State and Feedback With useFormState and useFormStatus

The basic form works but doesn't give users feedback. useFormState captures the action's return value; useFormStatus provides the pending state.

// app/contact/ContactForm.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { submitContact } from '@/app/actions/contact';

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

  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
    >
      {pending ? 'Sending...' : 'Send Message'}
    </button>
  );
}

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

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

  if (state.success) {
    return (
      <div className="p-4 bg-green-50 text-green-800 rounded">
        Message sent successfully. We'll be in touch.
      </div>
    );
  }

  return (
    <form action={formAction} className="space-y-4">
      {state.error && (
        <div className="p-3 bg-red-50 text-red-700 rounded text-sm">
          {state.error}
        </div>
      )}

      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">
          Name
        </label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          Email
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          Message
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="block w-full border rounded px-3 py-2"
        />
      </div>

      <SubmitButton />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note that SubmitButton is a separate component — useFormStatus only works inside a form element, not in the component that renders the form. Moving it to a child component solves this.

Server Actions With Zod Validation

For robust validation, combine Server Actions with Zod:

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

import { z } from 'zod';

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

type FormState = {
  success: boolean;
  error?: string;
  fieldErrors?: Record<string, string[]>;
};

export async function submitContact(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = ContactSchema.safeParse(raw);

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

  try {
    await saveToDatabase(result.data);
    return { success: true };
  } catch {
    return { success: false, error: 'Something went wrong. Please try again.' };
  }
}
Enter fullscreen mode Exit fullscreen mode

The fieldErrors object maps field names to arrays of error messages — useful for displaying inline validation errors next to each field.

Mutations Beyond Forms

Server Actions aren't limited to forms. You can call them from event handlers in Client Components:

'use client';

import { deleteItem } from '@/app/actions/items';

export function DeleteButton({ id }: { id: string }) {
  return (
    <button
      onClick={async () => {
        await deleteItem(id);
      }}
      className="text-red-600 hover:text-red-800"
    >
      Delete
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the action:

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

import { revalidatePath } from 'next/cache';

export async function deleteItem(id: string) {
  await db.items.delete({ where: { id } });
  revalidatePath('/items');
}
Enter fullscreen mode Exit fullscreen mode

revalidatePath tells Next.js to invalidate the cached data for a route after a mutation — so the list updates without a full page reload.

When to Use Server Actions vs API Routes

Server Actions are better for: form submissions, mutations triggered by user interaction, operations that need to revalidate cached data, and when you want progressive enhancement (works without JS).

API Routes are better for: webhook handlers, publicly accessible endpoints that external services call, streaming responses, and when you need fine-grained control over the HTTP response (status codes, headers, streaming).

The mental model: Server Actions are for your own UI. API Routes are for the outside world or situations where HTTP semantics matter.

Summary

Server Actions simplify the form handling pattern significantly. Instead of form → fetch('/api/submit') → handler → response → update state, you get form action → server function → return result. Less code, built-in progressive enhancement, and revalidatePath for cache invalidation after mutations.

The key files: the action function (marked 'use server'), the form component using useFormState for result capture, and a SubmitButton child component for useFormStatus access. That pattern covers the vast majority of form handling in Next.js applications.

Optimistic Updates With useOptimistic

For UI that should feel instant, useOptimistic lets you show the expected result before the server confirms it:

'use client';

import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions/likes';

type Post = { id: string; liked: boolean; likeCount: number };

export function LikeButton({ post }: { post: Post }) {
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (state, liked: boolean) => ({
      ...state,
      liked,
      likeCount: liked ? state.likeCount + 1 : state.likeCount - 1,
    })
  );

  const handleLike = async () => {
    setOptimisticPost(!optimisticPost.liked);
    await toggleLike(post.id);
  };

  return (
    <button onClick={handleLike} className="flex items-center gap-1">
      <span>{optimisticPost.liked ? '❤️' : '🤍'}</span>
      <span>{optimisticPost.likeCount}</span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The UI updates immediately on click. If the server action fails, the optimistic state reverts automatically. For interactions where users expect instant feedback, this pattern eliminates the perception of latency.

Top comments (0)