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.