DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Next.js Server Actions — Stop Writing API Routes for Forms

I used to write an API route for every form submission.

Register form → /api/auth/register
Contact form → /api/contact
Update profile → /api/users/update

Server Actions changed everything.

Here's how I use them in every Next.js 15 project now.


What Are Server Actions

Server Actions are async functions that run on the server — called directly from your components. No API route needed.

// Before Server Actions
async function handleSubmit(e) {
  e.preventDefault();
  const res = await fetch('/api/contact', {
    method: 'POST',
    body: JSON.stringify({ name, email, message })
  });
  const data = await res.json();
}

// With Server Actions
async function submitContact(formData: FormData) {
  'use server';
  const name = formData.get('name');
  const email = formData.get('email');
  // directly save to database
  await db.contacts.create({ name, email });
}
Enter fullscreen mode Exit fullscreen mode

The 'use server' directive tells Next.js this function runs on the server even when called from a client component.


1. Basic Form with Server Action

// app/contact/page.tsx
export default function ContactPage() {
  async function submitForm(formData: FormData) {
    'use server';

    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) {
      throw new Error('All fields required');
    }

    // Save to database directly
    await db.messages.create({ data: { name, email, message } });
  }

  return (
    <form action={submitForm}>
      <input name="name" placeholder="Your name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <button type="submit">Send Message</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

No useState. No fetch. No API route. Just a function.


2. With Loading and Error States

'use client';
import { useActionState } from 'react';

async function submitForm(prevState: any, formData: FormData) {
  'use server';

  try {
    const email = formData.get('email') as string;
    await db.subscribers.create({ data: { email } });
    return { success: true, message: 'Subscribed successfully!' };
  } catch (error) {
    return { success: false, message: 'Something went wrong.' };
  }
}

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

  return (
    <form action={action}>
      <input name="email" type="email" placeholder="Enter email" />

      <button type="submit" disabled={isPending}>
        {isPending ? 'Subscribing...' : 'Subscribe'}
      </button>

      {state?.message && (
        <p className={state.success ? 'text-green-400' : 'text-red-400'}>
          {state.message}
        </p>
      )}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

useActionState gives you pending state and the previous result automatically.


3. Revalidating Data After Mutation

After updating data you need to refresh the UI. Use revalidatePath:

// actions/posts.ts
'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.posts.create({ data: { title, content } });

  // Refresh the posts page so new post appears
  revalidatePath('/dashboard/posts');
}

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

Usage in component:

import { deletePost } from '@/actions/posts';

export function PostCard({ post }) {
  return (
    <div>
      <h3>{post.title}</h3>
      {/* Pass server action directly to form action */}
      <form action={deletePost.bind(null, post.id)}>
        <button type="submit">Delete</button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Redirect After Action

'use server';
import { redirect } from 'next/navigation';

export async function login(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  const user = await verifyCredentials(email, password);

  if (!user) {
    return { error: 'Invalid credentials' };
  }

  // Set cookie
  cookies().set('auth-token', generateToken(user.id), {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24 * 7,
  });

  // Redirect to dashboard
  redirect('/dashboard');
}
Enter fullscreen mode Exit fullscreen mode

5. Separate Actions File (Best Practice)

Don't put all your actions inside components. Create a dedicated file:

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

import { connectDB } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import User from '@/models/User';

export async function updateProfile(formData: FormData) {
  await connectDB();

  const name = formData.get('name') as string;
  const userId = formData.get('userId') as string;

  await User.findByIdAndUpdate(userId, { name });
  revalidatePath('/dashboard/settings');
}

export async function deleteAccount(userId: string) {
  await connectDB();
  await User.findByIdAndDelete(userId);
  redirect('/');
}
Enter fullscreen mode Exit fullscreen mode

Import and use anywhere:

import { updateProfile } from '@/actions/users';

export function ProfileForm({ user }) {
  return (
    <form action={updateProfile}>
      <input type="hidden" name="userId" value={user.id} />
      <input name="name" defaultValue={user.name} />
      <button type="submit">Save Changes</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to Still Use API Routes

Server Actions are not always the answer:

Use Server Actions Use API Routes
Form submissions Third party webhooks
CRUD operations Mobile app backend
Page mutations Public API
Auth flows File uploads

If an external service needs to call your endpoint — use an API route. If it's your own Next.js app talking to itself — use Server Actions.


Summary

Old way:  Component → fetch → API Route → Database
New way:  Component → Server Action → Database
Enter fullscreen mode Exit fullscreen mode

Less code. Faster development. Same result.

I use Server Actions in all my Next.js templates for contact forms, auth flows, and dashboard mutations.

See them in action:

Get the templates: https://pixelanas.gumroad.com

What do you use — Server Actions or API Routes? Drop it below 👇


Anas — full-stack Next.js developer. X: @pixelanas

Top comments (0)