DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js Server Actions in 2026: replace your API routes with something better

Server Actions were the most controversial Next.js feature when they launched. A year later, they've become my default pattern for form handling and mutations — but only when used correctly. Here's how to use them well and where they actually beat API routes.

What Server Actions actually are

A Server Action is an async function that runs on the server but can be called directly from client components — no fetch, no API route, no manual serialization.

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

import { db } from '@/lib/db';
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.post.create({
    data: { title, content, authorId: getCurrentUser().id },
  });

  revalidatePath('/posts');
}
Enter fullscreen mode Exit fullscreen mode
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. No useState, no fetch('/api/posts'), no request/response boilerplate.

The pattern that makes Server Actions actually good

Raw FormData gets messy fast. The pattern I use combines Server Actions with Zod validation and structured return types:

// lib/actions/types.ts
export type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };
Enter fullscreen mode Exit fullscreen mode
// app/actions/posts.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { getServerSession } from 'next-auth';
import type { ActionResult } from '@/lib/actions/types';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  published: z.boolean().default(false),
});

export async function createPost(
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return { success: false, error: 'Unauthorized' };
  }

  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'true',
  });

  if (!parsed.success) {
    return {
      success: false,
      error: 'Validation failed',
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  const post = await db.post.create({
    data: { ...parsed.data, authorId: session.user.id },
  });

  revalidatePath('/posts');
  return { success: true, data: { id: post.id } };
}
Enter fullscreen mode Exit fullscreen mode

Now the client side, using useActionState (React 19 / Next.js 14):

'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    <form action={formAction}>
      <div>
        <input name="title" aria-invalid={!!state?.fieldErrors?.title} />
        {state?.fieldErrors?.title && (
          <p className="text-red-500">{state.fieldErrors.title[0]}</p>
        )}
      </div>

      <div>
        <textarea name="content" aria-invalid={!!state?.fieldErrors?.content} />
        {state?.fieldErrors?.content && (
          <p className="text-red-500">{state.fieldErrors.content[0]}</p>
        )}
      </div>

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

      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

This gives you: validation, error display, loading state — with zero API routes.

Server Actions vs API Routes: when to use which

Use Server Actions when:

  • Form submissions and mutations from your own UI
  • Operations that always require authentication
  • Actions where you want revalidatePath/revalidateTag to work automatically
  • Anything that previously needed a simple POST endpoint

Keep API Routes when:

  • External services need to call your endpoints (webhooks, OAuth callbacks)
  • You're building a public API consumed by mobile apps or third parties
  • You need granular HTTP status codes (204, 301, etc.)
  • File uploads with streaming

Composing actions

The best pattern for complex mutations: compose small actions.

// lib/actions/auth.ts
'use server';

export async function requireAuth() {
  const session = await getServerSession();
  if (!session?.user?.id) throw new Error('Unauthorized');
  return session.user;
}
Enter fullscreen mode Exit fullscreen mode
// app/actions/billing.ts
'use server';

import { requireAuth } from '@/lib/actions/auth';
import { stripe } from '@/lib/stripe';

export async function createCheckoutSession(priceId: string) {
  const user = await requireAuth();

  const session = await stripe.checkout.sessions.create({
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    metadata: { userId: user.id },
  });

  return { url: session.url };
}
Enter fullscreen mode Exit fullscreen mode

Progressive enhancement

One underrated benefit: Server Actions work without JavaScript enabled (for native <form> elements). Your forms are accessible by default and work even if the client bundle fails to load.

The mistake everyone makes

Don't put 'use server' at the top of a component file. It marks every exported function as a Server Action.

// Wrong — everything exported is now a Server Action
'use server';
export function MyComponent() { /* ... */ }
export async function createPost() { /* ... */ }

// Right — separate files
// actions.ts
'use server';
export async function createPost() { /* ... */ }

// components/MyComponent.tsx
import { createPost } from '@/app/actions';
export function MyComponent() {
  return <form action={createPost}>...</form>;
}
Enter fullscreen mode Exit fullscreen mode

This pattern (Server Actions + Zod + useActionState) is baked into the AI SaaS Starter Kit at whoffagents.com. Ships with auth, Stripe billing, and full form handling patterns pre-configured so you skip the setup and start building.

Top comments (0)