Skip to main content

What are Server Actions?

Server Actions are asynchronous functions that run on the server. They can be called from Client Components and Server Components, providing a seamless way to perform server-side operations like data mutations, API calls, and database operations.
Server Actions are marked with the "use server" directive and always run on the server, even when called from client-side code.

The “use server” Directive

Every server action file must start with the "use server" directive at the top: From src/lib/actions/message/createMessage.ts:1:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
The "use server" directive must be at the very top of the file, before any imports.

Server Action Structure

All server actions in this project follow a consistent pattern:
1

Define State Interface

Define TypeScript interface for the return state
2

Create Zod Schema

Define validation schema for input data
3

Implement Action Function

Create async function that validates, processes, and returns state
4

Revalidate Cache

Call revalidatePath() to refresh cached data after mutations

Complete Server Action Example

Let’s examine a full server action for creating messages: From src/lib/actions/message/createMessage.ts:
interface MessagePrevState {
  success?: boolean;
  error?: string;
}

Form Validation with Zod

All server actions use Zod for runtime type validation:

Basic Validation

From src/lib/actions/people/createPerson.ts:11-16:
const personSchema = z.object({
  name: z.string().min(1, "Name cannot be empty"),
  phone: z
    .string()
    .regex(/^8801\d{9}$/, "Invalid phone number format. Must be 8801XXXXXXXXX"),
});

Validation with safeParse

Use safeParse to validate without throwing errors:
const parsedData = personSchema.safeParse({
  name: formData.get("name"),
  phone: formData.get("phone"),
});

if (!parsedData.success) {
  return {
    error: parsedData.error.errors.map((err) => err.message).join(", "),
  };
}

// parsedData.data is now type-safe
const { name, phone } = parsedData.data;
safeParse returns an object with a success boolean and either data (if valid) or error (if invalid).

Complex Validation

From src/lib/actions/message/updateMessage.ts:11-19:
const messageSchema = z.object({
  id: z.string().min(1, "ID is required"),
  content: z.string().min(1, "Content cannot be empty"),
  sendToPhone: z
    .string()
    .regex(/^8801\d{9}$/, "Invalid phone number format. Must be 8801XXXXXXXXX"),
  sendAfter: z
    .number().min(0, "Send after is required and minimum after 0 day"),
});

Using Server Actions in Client Components

Server Actions integrate seamlessly with the useActionState hook (React 19): From src/components/CreateMessageForm.tsx:22-39:
"use client";

import { useActionState, useEffect } from "react";
import createMessage from "@/lib/actions/message/createMessage";
import { toast } from "sonner";

export default function MessageUpdateForm({ people }: { people: People[] }) {
  const [createState, createAction, createIsPending] = useActionState(
    createMessage,
    {}
  );
  
  useEffect(() => {
    if (createState.success) {
      toast.success("Message created successfully", {
        richColors: true,
      });
    }
    if (createState.error) {
      toast.error("Message creation failed", {
        description: createState.error,
        duration: 3500,
        richColors: true,
      });
    }
  }, [createState]);

  return (
    <form action={createAction} className="space-y-3 flex flex-col">
      <Textarea name="content" />
      <Button type="submit" disabled={createIsPending}>
        Add
      </Button>
    </form>
  );
}

useActionState Hook

The useActionState hook returns three values:

createState

Current state returned from the server action

createAction

Function to pass to form’s action prop

createIsPending

Boolean indicating if action is currently running

The revalidatePath Pattern

After mutating data, you must revalidate the cache to reflect changes: From src/lib/actions/message/createMessage.ts:58-59:
revalidatePath("/");
return { success: true };
From src/lib/actions/reschedule/reschedule.ts:24-25:
revalidatePath("/");
return { success: true };
revalidatePath("/") tells Next.js to re-fetch all data for the home page, ensuring the UI reflects the latest database state.

Why revalidatePath is Important

1

Cache Invalidation

Next.js caches Server Component renders for performance. revalidatePath clears this cache.
2

Automatic Re-render

After cache invalidation, Server Components automatically re-fetch data and re-render.
3

Consistent UI

Users see updated data immediately without manual page refresh.

Error Handling Pattern

All server actions use consistent error handling:
try {
  // Validation
  const parsedData = schema.safeParse(data);
  if (!parsedData.success) {
    return { error: "Validation error message" };
  }

  // API call
  const response = await fetch(url, options);
  if (!response.ok) {
    return { error: `Backend Error: ${response.status}` };
  }

  // Success
  revalidatePath("/");
  return { success: true };
} catch (error) {
  return {
    error: error instanceof Error ? error.message : "Something went wrong",
  };
}
Always return error messages to the client instead of throwing errors. This provides better user experience and debugging information.

Server Actions Without Forms

Server Actions can be called directly without forms: From src/lib/actions/reschedule/reschedule.ts:10-29:
"use server"

import { revalidatePath } from "next/cache";

interface RescheduleState {
    success: boolean;
    error?: string;
}

export default async function reschedule(): Promise<RescheduleState> {
    try {
        const response = await fetch(`${process.env.BACKEND_URL}/messages/reschedule`, {
            method: "GET",
            headers: {
                Authorization: `Basic ${Buffer.from(
                    `${process.env.USERNAME}:${process.env.PASSWORD}`
                ).toString("base64")}`,
            },
        })
        if (!response.ok) {
            return { success: false };
        }
        revalidatePath("/");
        return { success: true };
    } catch (e) {
        return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
    }
}
This action takes no form data and can be triggered by a button click.

Real-World Examples

From src/lib/actions/people/createPerson.ts:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

interface PersonPrevState {
  success?: boolean;
  error?: string;
}

const personSchema = z.object({
  name: z.string().min(1, "Name cannot be empty"),
  phone: z
    .string()
    .regex(/^8801\d{9}$/, "Invalid phone number format. Must be 8801XXXXXXXXX"),
});

export default async function createPerson(
  prevState: PersonPrevState,
  formData: FormData
): Promise<PersonPrevState> {
  try {
    const parsedData = personSchema.safeParse({
      name: formData.get("name"),
      phone: formData.get("phone"),
    });

    if (!parsedData.success) {
      return {
        error: parsedData.error.errors.map((err) => err.message).join(", "),
      };
    }

    const response = await fetch(
      `${process.env.BACKEND_URL}/people/create-one`,
      {
        method: "POST",
        headers: {
          Authorization: `Basic ${Buffer.from(
            `${process.env.USERNAME}:${process.env.PASSWORD}`
          ).toString("base64")}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(parsedData.data),
      }
    );

    if (!response.ok) {
      return {
        error: `Backend Error: ${response.status} ${response.statusText}`,
      };
    }
    revalidatePath("/");
    return { success: true };
  } catch (error) {
    return {
      error: error instanceof Error ? error.message : "Something went wrong",
    };
  }
}
From src/lib/actions/message/updateMessage.ts:21-66:
export default async function updateMessage(
  prevState: MessagePrevState,
  formData: FormData
): Promise<MessagePrevState> {
  try {
    const parsedData = messageSchema.safeParse({
      id: formData.get("id"),
      content: formData.get("content"),
      sendToPhone: formData.get("sendToPhone"),
      sendAfter: Number(formData.get("sendAfter")),
    });

    if (!parsedData.success) {
      return {
        error: parsedData.error.errors.map((err) => err.message).join(", "),
      };
    }

    const response = await fetch(
      `${process.env.BACKEND_URL}/messages/update-one/${parsedData.data.id}`,
      {
        method: "POST",
        headers: {
          Authorization: `Basic ${Buffer.from(
            `${process.env.USERNAME}:${process.env.PASSWORD}`
          ).toString("base64")}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(parsedData.data),
      }
    );

    if (!response.ok) {
      return {
        error: `Backend Error: ${response.status} ${response.statusText}`,
      };
    }
    revalidatePath("/");
    return { success: true };
  } catch (error) {
    return {
      error: error instanceof Error ? error.message : "Something went wrong",
    };
  }
}

Best Practices

Always Validate

Use Zod schemas to validate all input data before processing

Return State

Always return success/error state instead of throwing errors

Revalidate Cache

Call revalidatePath() after data mutations to update the UI

Type Safety

Define TypeScript interfaces for state and use Zod for runtime validation

Next Steps

Authentication

Learn how authentication works with Server Actions

API Reference

View all available Server Actions

Build docs developers (and LLMs) love