Server Actions in Next.js ⚔

Introduction šŸ‘‹

Server Actions let you write server-side mutation logic as plain async functions and call them directly from your components — no manually built API route required. They bridge the gap between Client Components and server-side data mutations. This tutorial covers everything from the basics of creating an action to advanced patterns like optimistic updates.

Information

Server Actions work seamlessly in both Server and Client Components, and integrate with native HTML <form> elements.

What are Server Actions? 🧠

A Server Action is an asynchronous function that runs exclusively on the server, marked with the "use server" directive. It can be called from a form's action prop, or invoked directly as a regular function from event handlers.

app/actions.ts

"use server";

export async function createTodo(formData: FormData) {
  const title = formData.get("title");
  await db.todos.create({ title });
}

Why Use Server Actions? šŸ¤”

  • Eliminates the need to hand-build API routes just for simple mutations.
  • Runs on the server, so secrets and database access never reach the client.
  • Integrates naturally with <form> for progressive enhancement — works even before JavaScript loads.
  • Pairs with revalidatePath/revalidateTag for automatic cache updates after a mutation.

How Server Actions Work āš™ļø

When a Server Action is called, Next.js sends a special HTTP POST request encoding the arguments, executes the function on the server, and streams the result back — all without a full page reload.

Client Component
Invoke Server Action
Server Executes Function
Result Returned to Client

The "use server" Directive šŸ·ļø

The "use server" directive marks a function — or an entire file — as containing Server Actions. It can be placed at the top of a file to apply to every export, or inline at the top of a single function.

app/page.tsx

export default function Page() {
  async function like() {
    "use server";
    await db.likes.increment();
  }

  return <form action={like}><button type="submit">Like</button></form>;
}

Important

Inline "use server" functions MUST be declared inside a Server Component — Client Components must import actions from a separate file.

Creating Server Actions šŸ› ļø

The most common pattern is defining Server Actions in a dedicated file with "use server" at the top, keeping mutation logic organized and importable from anywhere in the app.

app/actions.ts

"use server";

import { db } from "@/lib/db";

export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } });
}

Invoking Server Actions šŸ“ž

Server Actions can be invoked in two ways: passed directly to a form's action prop, or called imperatively inside an event handler within a Client Component.

components/DeleteButton.tsx

"use client";

import { deletePost } from "@/app/actions";

export default function DeleteButton({ id }: { id: string }) {
  return <button onClick={() => deletePost(id)}>Delete</button>;
}

Server Actions with Forms šŸ“‹

Passing a Server Action directly to a <form>'s action prop is the most idiomatic pattern — it works with zero client JavaScript, thanks to progressive enhancement.

components/NewTodo.tsx

import { createTodo } from "@/app/actions";

export default function NewTodo() {
  return (
    <form action={createTodo}>
      <input name="title" type="text" />
      <button type="submit">Add Todo</button>
    </form>
  );
}

Form Submission šŸ“Ø

When a form using a Server Action is submitted, Next.js automatically serializes the form fields into a FormData object and passes it as the first argument to the action.

app/actions.ts

"use server";

export async function submitContact(formData: FormData) {
  const name = formData.get("name");
  const email = formData.get("email");
  await db.contacts.create({ name, email });
}

Form Validation āœ…

Validation typically happens at the start of the action, often using a schema library like Zod to parse and validate the incoming FormData before touching the database.

app/actions.ts

"use server";

import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
});

export async function subscribe(formData: FormData) {
  const result = schema.safeParse({ email: formData.get("email") });
  if (!result.success) {
    return { error: "Invalid email address" };
  }
  await db.subscribers.create({ email: result.data.email });
  return { success: true };
}

Reading Form Data šŸ“–

The FormData object passed into a Server Action exposes get(), getAll(), and iteration methods, letting you extract text fields, files, and multi-value fields like checkboxes.

app/actions.ts

"use server";

export async function saveProfile(formData: FormData) {
  const name = formData.get("name") as string;
  const interests = formData.getAll("interests") as string[];
  await db.profiles.update({ name, interests });
}

Returning Data šŸ“¤

A Server Action can return a plain, serializable value, which becomes available to the calling component — commonly used to signal success or return newly created data.

app/actions.ts

"use server";

export async function createComment(formData: FormData) {
  const comment = await db.comments.create({ text: formData.get("text") });
  return { id: comment.id, createdAt: comment.createdAt };
}

Returning Errors āš ļø

Rather than throwing for expected validation failures, it's common practice to return a structured error object, which pairs naturally with the useActionState hook on the client.

app/actions.ts

"use server";

export async function login(prevState: any, formData: FormData) {
  const password = formData.get("password");
  if (!password) {
    return { error: "Password is required" };
  }
  return { success: true };
}

Redirecting After Actions ā†Ŗļø

Calling redirect() inside a Server Action sends the user to a new route once the mutation completes — commonly used after creating a resource to navigate to its detail page.

app/actions.ts

"use server";

import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const post = await db.posts.create({ title: formData.get("title") });
  redirect(`/blog/${post.slug}`);
}

Error Handling 🚨

Unhandled exceptions thrown inside a Server Action are caught by the nearest error.tsx boundary, while expected, recoverable errors are better returned as structured data for inline display.

app/actions.ts

"use server";

export async function updateAccount(formData: FormData) {
  try {
    await db.accounts.update(formData.get("id"));
  } catch (e) {
    return { error: "Failed to update account" };
  }
}

Optimistic Updates šŸŒ€

Optimistic updates immediately reflect the expected result of a mutation in the UI, before the server confirms it — making interactions feel instant, with automatic rollback if the action fails.

components/LikeButton.tsx

"use client";

import { useOptimistic } from "react";
import { likePost } from "@/app/actions";

export default function LikeButton({ likes, postId }: { likes: number; postId: string }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (state) => state + 1);

  async function handleLike() {
    addOptimisticLike(1);
    await likePost(postId);
  }

  return <button onClick={handleLike}>ā¤ļø {optimisticLikes}</button>;
}

Pending States ā³

The isPending flag returned from useActionState or useFormStatus lets you show a loading indicator or disable a button while a Server Action is in flight.

components/SubmitButton.tsx

"use client";

import { useFormStatus } from "react-dom";

export default function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

Loading States šŸ”„

Combining pending states with disabled inputs and visual feedback — like a spinner or skeleton — prevents duplicate submissions and reassures the user their action was received.

components/NewComment.tsx

"use client";

import { useFormStatus } from "react-dom";
import { createComment } from "@/app/actions";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Posting…" : "Post"}</button>;
}

export default function NewComment() {
  return (
    <form action={createComment}>
      <textarea name="text" />
      <SubmitButton />
    </form>
  );
}

useActionState šŸŽ›ļø

useActionState wires a Server Action to component state, tracking the latest result and a pending flag across submissions — ideal for forms that need to display validation errors.

components/LoginForm.tsx

"use client";

import { useActionState } from "react";
import { login } from "@/app/actions";

export default function LoginForm() {
  const [state, formAction, isPending] = useActionState(login, { error: null });

  return (
    <form action={formAction}>
      <input name="password" type="password" />
      {state.error && <p>{state.error}</p>}
      <button disabled={isPending}>Log in</button>
    </form>
  );
}

useOptimistic šŸŒ€

useOptimistic creates a temporary, client-side version of state that updates immediately on interaction, then reconciles with the real server response once the action resolves.

components/TodoList.tsx

"use client";

import { useOptimistic } from "react";

export default function TodoList({ todos }: { todos: { id: string; text: string }[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (state, newTodo: string) => [
    ...state,
    { id: "temp", text: newTodo },
  ]);

  return (
    <List type="unordered">
      {optimisticTodos.map((todo) => <List.Item key={todo.id}>{todo.text}</List.Item>)}
    </List>
  );
}

useFormStatus šŸ“¶

useFormStatus reads the status of the parent <form>, and must be called from a component nested inside that form — not the form component itself.

components/SaveButton.tsx

"use client";

import { useFormStatus } from "react-dom";

export default function SaveButton() {
  const { pending, data } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

Caution

useFormStatus only reflects the status of the nearest ancestor form — it does not accept the form as a parameter.

Database Mutations šŸ—„ļø

Server Actions are a natural home for database mutations, since they run exclusively on the server and can call an ORM directly, without exposing any database credentials to the client.

app/actions.ts

"use server";

import { prisma } from "@/lib/prisma";

export async function updateUsername(id: string, formData: FormData) {
  await prisma.user.update({
    where: { id },
    data: { name: formData.get("name") as string },
  });
}

Authentication šŸ”

Server Actions can read session cookies via cookies() to identify the current user before performing any mutation, ensuring every action is tied to an authenticated identity.

app/actions.ts

"use server";

import { cookies } from "next/headers";

export async function updateBio(formData: FormData) {
  const session = cookies().get("session")?.value;
  if (!session) {
    return { error: "Not authenticated" };
  }
  await db.users.updateBio(session, formData.get("bio"));
}

Authorization šŸ›”ļø

Beyond checking who a user is, Server Actions should also verify what they're allowed to do — for example, confirming a user owns a resource before letting them delete it.

app/actions.ts

"use server";

export async function deletePost(id: string) {
  const session = await getSession();
  const post = await db.posts.findUnique({ where: { id } });

  if (post?.authorId !== session.userId) {
    throw new Error("Not authorized");
  }

  await db.posts.delete({ where: { id } });
}

Danger

NEVER trust client-supplied IDs alone for authorization — always re-verify ownership and permissions on the server inside the action itself.

File Uploads šŸ“Ž

Server Actions can accept files directly through FormData, using the File API to read and store uploaded content — such as saving an avatar image to cloud storage.

app/actions.ts

"use server";

export async function uploadAvatar(formData: FormData) {
  const file = formData.get("avatar") as File;
  const buffer = await file.arrayBuffer();
  await storage.upload(file.name, buffer);
}

components/AvatarForm.tsx

import { uploadAvatar } from "@/app/actions";

export default function AvatarForm() {
  return (
    <form action={uploadAvatar}>
      <input type="file" name="avatar" accept="image/*" />
      <button type="submit">Upload</button>
    </form>
  );
}

Cache Revalidation šŸ”

Since Server Actions mutate data, they're the natural place to trigger cache invalidation, ensuring the UI reflects the change immediately after the action completes.

app/actions.ts

"use server";

import { revalidatePath } from "next/cache";

export async function addComment(formData: FormData) {
  await db.comments.create({ text: formData.get("text") });
  revalidatePath("/blog/post");
}

revalidatePath šŸ›£ļø

revalidatePath invalidates the cache for a specific route, causing Next.js to regenerate it with fresh data the next time it's visited.

app/actions.ts

"use server";

import { revalidatePath } from "next/cache";

export async function toggleTask(id: string) {
  await db.tasks.toggle(id);
  revalidatePath("/tasks");
}

revalidateTag šŸ·ļø

revalidateTag invalidates every cached fetch() call sharing a given tag, which is useful when the same data appears across multiple, unrelated routes.

app/actions.ts

"use server";

import { revalidateTag } from "next/cache";

export async function updatePrice(id: string, price: number) {
  await db.products.updatePrice(id, price);
  revalidateTag("products");
}

Security Considerations šŸ”’

  • Server Actions are exposed as public HTTP endpoints — always re-validate input, even if the client already validated it.
  • Never assume a client-supplied ID grants access — always check authorization server-side.
  • Rate-limit sensitive actions like password resets or payment mutations.
  • Avoid leaking internal error details in returned error messages.

Performance Optimization šŸš€

  • Use revalidateTag instead of broad revalidatePath calls for more targeted cache invalidation.
  • Pair actions with useOptimistic to keep the UI feeling instant, even on slower connections.
  • Keep action payloads small — avoid passing large objects unnecessarily through FormData.

Best Practices āœ…

  • Colocate related Server Actions in a dedicated actions.ts file per feature.
  • Always validate and authorize inside the action — never trust the client.
  • Prefer returning structured error objects over throwing for expected validation failures.
  • Use useFormStatus and useActionState together for polished, accessible forms.

Common Mistakes āš ļø

  • Forgetting "use server" at the top of the actions file or function.
  • Calling useFormStatus in the same component that renders the <form>, instead of a child component.
  • Skipping server-side authorization checks because the client already hid the button.
  • Forgetting to call revalidatePath or revalidateTag, leaving stale cached data visible.

Frequently Asked Questions šŸ’¬

Question

Can Server Actions be called from Server Components?

Answer

Yes — they can be passed directly to a form's action prop, or defined inline inside the Server Component itself.

Question

Do Server Actions work without JavaScript enabled?

Answer

Yes, when used with a native <form> — this is a core benefit of their progressive enhancement design.

Question

Is it safe to pass sensitive data through a Server Action?

Answer

Arguments are sent over the network like any request body, so always use HTTPS and never include secrets that the client shouldn't see.

Summary šŸ“Œ

Summary

Server Actions replace boilerplate API routes with simple, secure, server-side functions — making mutations feel like a natural extension of your components rather than a separate layer.