Skip to main content
Back to Blog
Tutorials
3 min read
December 25, 2024

How to Implement Server Actions Patterns in Next.js

Master Next.js Server Actions with patterns for form handling, optimistic updates, error handling, revalidation, and progressive enhancement.

Ryel Banfield

Founder & Lead Developer

Server Actions let you run server-side code directly from React components. Here are the patterns that matter.

Basic Form Action

// app/contact/page.tsx
import { revalidatePath } from "next/cache";

async function submitContact(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
  // await db.insert(contacts).values({ name, email, message });

  revalidatePath("/admin/contacts");
}

export default function ContactPage() {
  return (
    <form action={submitContact} className="max-w-md space-y-4">
      <input name="name" placeholder="Name" required className="w-full border rounded px-3 py-2" />
      <input name="email" type="email" placeholder="Email" required className="w-full border rounded px-3 py-2" />
      <textarea name="message" placeholder="Message" required className="w-full border rounded px-3 py-2" />
      <button type="submit" className="bg-primary text-primary-foreground px-4 py-2 rounded">
        Send
      </button>
    </form>
  );
}

Type-Safe Action with Return Values

// lib/actions.ts
"use server";

import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  message: z.string().min(10).max(1000),
});

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };

export async function createContact(
  _prev: ActionResult | null,
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  const raw = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };

  const result = contactSchema.safeParse(raw);

  if (!result.success) {
    const fieldErrors: Record<string, string[]> = {};
    for (const [key, messages] of Object.entries(result.error.flatten().fieldErrors)) {
      if (messages) fieldErrors[key] = messages;
    }
    return { success: false, error: "Validation failed", fieldErrors };
  }

  try {
    // const [row] = await db.insert(contacts).values(result.data).returning({ id: contacts.id });
    const id = crypto.randomUUID();
    return { success: true, data: { id } };
  } catch {
    return { success: false, error: "Failed to save. Please try again." };
  }
}

Using useActionState

// components/ContactForm.tsx
"use client";

import { useActionState } from "react";
import { createContact } from "@/lib/actions";

export function ContactForm() {
  const [state, action, pending] = useActionState(createContact, null);

  return (
    <form action={action} className="max-w-md space-y-4">
      {state?.success === false && (
        <div className="bg-red-50 border border-red-200 rounded p-3 text-red-700 text-sm">
          {state.error}
        </div>
      )}

      {state?.success === true && (
        <div className="bg-green-50 border border-green-200 rounded p-3 text-green-700 text-sm">
          Message sent successfully.
        </div>
      )}

      <div>
        <input name="name" placeholder="Name" required className="w-full border rounded px-3 py-2" />
        {state?.fieldErrors?.name && (
          <p className="text-red-600 text-xs mt-1">{state.fieldErrors.name[0]}</p>
        )}
      </div>

      <div>
        <input name="email" type="email" placeholder="Email" required className="w-full border rounded px-3 py-2" />
        {state?.fieldErrors?.email && (
          <p className="text-red-600 text-xs mt-1">{state.fieldErrors.email[0]}</p>
        )}
      </div>

      <div>
        <textarea name="message" placeholder="Message" required className="w-full border rounded px-3 py-2" />
        {state?.fieldErrors?.message && (
          <p className="text-red-600 text-xs mt-1">{state.fieldErrors.message[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={pending}
        className="bg-primary text-primary-foreground px-4 py-2 rounded disabled:opacity-50"
      >
        {pending ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}

Optimistic Updates

"use client";

import { useOptimistic, useTransition } from "react";
import { toggleFavorite } from "@/lib/actions";

interface Item {
  id: string;
  name: string;
  favorited: boolean;
}

export function ItemList({ items }: { items: Item[] }) {
  const [optimisticItems, addOptimistic] = useOptimistic(
    items,
    (current, toggledId: string) =>
      current.map((item) =>
        item.id === toggledId ? { ...item, favorited: !item.favorited } : item
      )
  );
  const [, startTransition] = useTransition();

  function handleToggle(id: string) {
    startTransition(async () => {
      addOptimistic(id);
      await toggleFavorite(id);
    });
  }

  return (
    <ul className="space-y-2">
      {optimisticItems.map((item) => (
        <li key={item.id} className="flex items-center gap-3 p-3 border rounded">
          <button onClick={() => handleToggle(item.id)}>
            {item.favorited ? "★" : "☆"}
          </button>
          <span>{item.name}</span>
        </li>
      ))}
    </ul>
  );
}

Reusable Action Creator

// lib/create-action.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string };

export function createAction<TInput, TOutput>(config: {
  schema: z.ZodSchema<TInput>;
  handler: (input: TInput) => Promise<TOutput>;
  revalidate?: string;
}) {
  return async (_prev: ActionResult<TOutput> | null, formData: FormData): Promise<ActionResult<TOutput>> => {
    const raw = Object.fromEntries(formData.entries());
    const result = config.schema.safeParse(raw);

    if (!result.success) {
      return { success: false, error: result.error.errors[0].message };
    }

    try {
      const data = await config.handler(result.data);
      if (config.revalidate) revalidatePath(config.revalidate);
      return { success: true, data };
    } catch (err) {
      return {
        success: false,
        error: err instanceof Error ? err.message : "Something went wrong",
      };
    }
  };
}

// Usage
export const updateProfile = createAction({
  schema: z.object({
    name: z.string().min(1),
    bio: z.string().max(500).optional(),
  }),
  handler: async (input) => {
    // await db.update(users).set(input).where(eq(users.id, getCurrentUserId()));
    return { updated: true };
  },
  revalidate: "/dashboard/profile",
});

Non-Form Usage (Event Handlers)

"use client";

import { deleteItem } from "@/lib/actions";
import { useTransition } from "react";

export function DeleteButton({ id }: { id: string }) {
  const [pending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(() => deleteItem(id))}
      disabled={pending}
      className="text-red-600 text-sm disabled:opacity-50"
    >
      {pending ? "Deleting..." : "Delete"}
    </button>
  );
}

Need a Modern Next.js Application?

We build fast, reliable web apps using the latest Next.js patterns. Contact us to discuss your project.

Server ActionsNext.jsformsmutationstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles