Server Actions
Server Actions are async functions that run on the server, triggered from the client. They replace most API routes for mutations: form submissions, data updates, deletes. They work without JavaScript enabled and integrate directly with React’s form handling.
Defining Server Actions
Two ways to define them:
Inline in a Server Component
// app/posts/new/page.tsx (Server Component)
export default function NewPost() {
async function create(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
}
return (
<form action={create}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
In a separate file
// actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 } });
revalidatePath("/posts");
redirect("/posts");
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/actions/posts";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
Tip: Use the separate file approach when multiple components share the same action, or when you want to organize mutations in one place.
useActionState
React’s useActionState hook gives you pending state and error handling for Server Actions:
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/posts";
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, { error: null });
return (
<form action={action}>
<input name="title" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
{state.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
The action needs to return state:
// actions/posts.ts
"use server";
type ActionState = { error: string | null };
export async function createPost(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const title = formData.get("title") as string;
if (title.length < 3) {
return { error: "Title must be at least 3 characters" };
}
try {
await db.post.create({ data: { title } });
revalidatePath("/posts");
} catch {
return { error: "Failed to create post" };
}
redirect("/posts");
}
Gotcha: When using
useActionState, the action receives(prevState, formData)instead of just(formData). The signature changes.
Progressive Enhancement
Forms with Server Actions work without JavaScript. The browser submits a regular form POST, and Next.js handles it server-side. When JavaScript is available, the submission is intercepted for a smoother SPA experience.
// This works even with JavaScript disabled
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
Non-Form Usage
Server Actions aren’t just for forms. Call them from event handlers:
"use client";
import { deletePost } from "@/actions/posts";
import { useTransition } from "react";
export function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
function handleDelete() {
startTransition(async () => {
await deletePost(postId);
});
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}
Tip: Wrap non-form Server Action calls in
startTransitionto avoid blocking the UI during the server round-trip.
Type-Safe Actions with next-safe-action
For production apps, use next-safe-action with Zod for input validation:
npm install next-safe-action zod
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();
// actions/posts.ts
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
});
export const createPost = actionClient
.schema(createPostSchema)
.action(async ({ parsedInput: { title, content } }) => {
const post = await db.post.create({ data: { title, content } });
revalidatePath("/posts");
return { post };
});
"use client";
import { useAction } from "next-safe-action/hooks";
import { createPost } from "@/actions/posts";
export function CreatePostForm() {
const { execute, result, isPending } = useAction(createPost);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
execute({
title: data.get("title") as string,
content: data.get("content") as string,
});
}}
>
<input name="title" required />
<textarea name="content" required />
<button disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
{result.validationErrors && (
<p className="text-red-600">Check your input</p>
)}
</form>
);
}
When NOT to Use Server Actions
| Use case | Use Server Actions? | Instead use |
|---|---|---|
| Form submissions | Yes | - |
| Data mutations from your UI | Yes | - |
| External webhooks | No | Route Handlers (route.ts) |
| Public REST/GraphQL API | No | Route Handlers |
| File uploads > 1MB | Maybe | Route Handlers or presigned URLs |
| Long-running tasks | No | Background jobs, queue systems |
| Real-time data | No | WebSockets, Server-Sent Events |
Gotcha: Server Actions are RPC calls, not REST endpoints. They don’t have stable URLs, so third-party services can’t call them. Use Route Handlers for anything external services need to reach.
Exercises
-
Like counter: Create a Server Action that increments a like count for a post. Display the count and a “Like” button. Use
revalidatePathto update the page after liking. -
Form validation: Build a contact form with
useActionState. Validate email format and message length on the server. Return field-specific errors and display them next to each input. -
Optimistic update: Create a todo list where checking off items uses
useOptimisticto show the change immediately, while the Server Action confirms it on the server. -
Protected action: Add authentication checking to a Server Action. If the user isn’t logged in (check cookies), return an error instead of performing the mutation.