Server Action Patterns: A Cookbook
Server Action Patterns: A Cookbook#
TL;DR -- Six patterns. Each one: problem, solution, code, gotcha. Bookmark this.
After a year of building production apps with server actions as the only mutation layer, these are the patterns I keep reaching for. I'm not going to explain what server actions are -- if you're reading post #32 in a React blog series, you know. This is a reference card.
The examples come from three apps: MoneyTrack, EduPlay, and BeautyGlam. All hexagonal architecture, all Next.js App Router, all server actions as the sole composition root.
Pattern 1: The Standard Mutation#
Covers 80% of cases. Validate, authenticate, execute use case, invalidate cache, return state.
// src/modules/money-track/infrastructure/actions/create-account-action.ts
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { getMongoCollection } from "@/lib/infrastructure/mongodb";
import { MongoAccountRepository } from "../persistence/mongo-account-repository";
import { CreateAccount } from "../../application/use-cases/create-account";
import { createAccountSchema } from "../../domain/schemas/account.schema";
type State = { error: string | null; success: boolean };
export async function createAccountAction(
_prev: State,
formData: FormData
): Promise<State> {
const session = await auth();
if (!session?.user?.id) {
return { error: "You must be logged in.", success: false };
}
// validate at the boundary, always
const parsed = createAccountSchema.safeParse({
name: formData.get("name"),
currency: formData.get("currency"),
type: formData.get("type"),
initialBalance: parseFloat(formData.get("initialBalance") as string),
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message, success: false };
}
const { collection } = await getMongoCollection("mt_accounts");
const repo = new MongoAccountRepository(collection);
const useCase = new CreateAccount(repo);
await useCase.execute({ ...parsed.data, userId: session.user.id });
revalidatePath("/s/money-track/accounts");
return { error: null, success: true };
}
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createAccountAction } from "@/modules/money-track/infrastructure/actions/create-account-action";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Creating..." : "Create Account"}
</button>
);
}
export function CreateAccountForm() {
const [state, formAction] = useActionState(createAccountAction, {
error: null,
success: false,
});
if (state.success) {
return <p className="text-green-700">Account created successfully.</p>;
}
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">Account Name</label>
<input id="name" name="name" className="mt-1 w-full rounded border px-3 py-2" />
</div>
<div>
<label htmlFor="currency" className="block text-sm font-medium">Currency</label>
<select id="currency" name="currency" className="mt-1 w-full rounded border px-3 py-2">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label htmlFor="initialBalance" className="block text-sm font-medium">Initial Balance</label>
<input id="initialBalance" name="initialBalance" type="number" step="0.01" defaultValue="0" className="mt-1 w-full rounded border px-3 py-2" />
</div>
<SubmitButton />
{state.error && (
<p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{state.error}</p>
)}
</form>
);
}
Gotcha: Always return the same state shape from every path. If your success path returns { success: true } but your error path returns { error: "msg" } without success, your UI will have inconsistent state. Define the type once and stick to it.
Pattern 2: Mutation with Redirect#
When success means navigating somewhere else. The critical rule: redirect() throws internally (NEXT_REDIRECT), so it must be outside any try/catch.
"use server";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export async function createPortfolioProjectAction(
_prev: { error: string | null },
formData: FormData
): Promise<{ error: string | null }> {
const session = await auth();
if (!session?.user?.id) {
return { error: "Unauthorized." };
}
const parsed = createProjectSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
let newProjectSlug: string;
try {
const useCase = new CreatePortfolioProject(repo);
const result = await useCase.execute({ ...parsed.data, userId: session.user.id });
newProjectSlug = result.slug;
} catch (err) {
return { error: "Failed to create project. Please try again." };
}
// redirect MUST be outside try/catch -- it throws NEXT_REDIRECT
redirect(`/admin/portfolio/projects/${newProjectSlug}`);
}
Gotcha: If you put redirect() inside the try block, the catch will swallow the NEXT_REDIRECT error and your redirect silently fails. I've done this at least three times. The error message is not helpful.
Pattern 3: Optimistic List Mutation#
For instant feedback on list operations -- add, remove, toggle. Combine useOptimistic with useTransition:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleSkillFeatureAction } from "@/modules/portfolio/infrastructure/actions/toggle-skill-feature-action";
import type { Skill } from "@/modules/portfolio/domain";
export function SkillList({ initialSkills }: { initialSkills: Skill[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticSkills, setOptimisticSkills] = useOptimistic(
initialSkills,
(current, { skillId, featured }: { skillId: string; featured: boolean }) =>
current.map((s) => (s.id === skillId ? { ...s, featured } : s))
);
function handleToggleFeatured(skill: Skill) {
startTransition(async () => {
setOptimisticSkills({ skillId: skill.id, featured: !skill.featured });
await toggleSkillFeatureAction({ skillId: skill.id });
});
}
return (
<ul className="space-y-2">
{optimisticSkills.map((skill) => (
<li key={skill.id} className="flex items-center justify-between rounded border px-4 py-3">
<span>{skill.name}</span>
<button
onClick={() => handleToggleFeatured(skill)}
disabled={isPending}
className={skill.featured ? "text-yellow-500" : "text-gray-300"}
aria-label={skill.featured ? "Unfeature skill" : "Feature skill"}
>
<StarIcon filled={skill.featured} />
</button>
</li>
))}
</ul>
);
}
Gotcha: The optimistic state reverts when the transition completes, not when your async function resolves. If you fire multiple rapid transitions (user clicking fast), each manages its own revert independently. Test with fast clicking.
Pattern 4: File Upload#
File uploads need extra validation on the server. The file arrives through FormData, the storage client lives in the infrastructure layer:
"use server";
import { put } from "@vercel/blob";
import { auth } from "@/lib/auth";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
type State = { error: string | null; imageUrl: string | null };
export async function uploadPortfolioImageAction(
_prev: State,
formData: FormData
): Promise<State> {
const session = await auth();
if (!session?.user?.id) {
return { error: "Unauthorized.", imageUrl: null };
}
const file = formData.get("image") as File | null;
if (!file || file.size === 0) {
return { error: "No file provided.", imageUrl: null };
}
if (file.size > MAX_FILE_SIZE) {
return { error: "File must be under 5 MB.", imageUrl: null };
}
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: "Only JPEG, PNG, and WebP images are accepted.", imageUrl: null };
}
// sanitize the filename
const filename = `portfolio/${session.user.id}/${Date.now()}-${file.name.replace(/[^a-z0-9.]/gi, "-")}`;
const blob = await put(filename, file, {
access: "public",
contentType: file.type,
});
return { error: null, imageUrl: blob.url };
}
"use client";
import { useActionState, useRef } from "react";
import { uploadPortfolioImageAction } from "./upload-portfolio-image-action";
export function ImageUploadForm({ onUploaded }: { onUploaded: (url: string) => void }) {
const [state, formAction, isPending] = useActionState(
async (prev: { error: string | null; imageUrl: string | null }, formData: FormData) => {
const result = await uploadPortfolioImageAction(prev, formData);
if (result.imageUrl) onUploaded(result.imageUrl);
return result;
},
{ error: null, imageUrl: null }
);
const inputRef = useRef<HTMLInputElement>(null);
return (
<form action={formAction} className="space-y-3">
<label htmlFor="image" className="block text-sm font-medium">
Project Image
</label>
<input
id="image"
ref={inputRef}
name="image"
type="file"
accept="image/jpeg,image/png,image/webp"
className="block w-full text-sm text-gray-500 file:mr-4 file:rounded file:border-0 file:bg-gray-100 file:px-4 file:py-2 file:text-sm"
/>
<button
type="submit"
disabled={isPending}
className="rounded bg-indigo-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{isPending ? "Uploading..." : "Upload Image"}
</button>
{state.error && (
<p className="text-sm text-red-600">{state.error}</p>
)}
{state.imageUrl && (
<img src={state.imageUrl} alt="Uploaded" className="h-32 w-32 rounded object-cover" />
)}
</form>
);
}
Gotcha: Client-side file validation (checking size/type before submission) is a UX nicety, not a security measure. Always validate on the server too. Users can bypass client validation trivially.
Pattern 5: Progressive Enhancement#
The underrated pattern. A form that works without JavaScript and gets enhanced with it:
// this form works with JS disabled -- it's just an HTML form
export function NewsletterSignup({ action }: { action: (formData: FormData) => Promise<void> }) {
return (
<form action={action} className="flex gap-2">
<label htmlFor="newsletter-email" className="sr-only">
Email address
</label>
<input
id="newsletter-email"
name="email"
type="email"
required
placeholder="you@example.com"
className="flex-1 rounded border px-3 py-2 text-sm"
/>
<button type="submit" className="rounded bg-indigo-600 px-4 py-2 text-sm text-white">
Subscribe
</button>
</form>
);
}
No "use client". The Server Component passes the action directly. The form submits, the page refreshes with updated state. Add "use client" and useActionState for in-page feedback without a full reload -- but the baseline works with zero JavaScript.
I use this for newsletter signups, feedback forms, and any public-facing form where I can't assume JavaScript is available. It's the kind of thing that Django gave us for free and we spent 8 years recreating in React.
Gotcha: None, really. This is the simplest pattern. If your form doesn't need client-side interactivity, don't add it.
Pattern 6: Hexagonal Composition#
The full picture of how a server action composes in hexagonal architecture:
FormData --> Server Action (validates, authenticates)
--> Use Case (domain logic, no infrastructure knowledge)
--> Repository (MongoDB / in-memory)
--> Domain Event (if needed)
--> Response --> revalidatePath --> return state
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { initModules, getSharedEventBus } from "@/lib/infrastructure";
import { getMongoCollection } from "@/lib/infrastructure/mongodb";
import { MongoCreatorProfileRepository } from "../persistence/mongo-creator-profile-repository";
import { UpdateCreatorBio } from "../../application/use-cases/update-creator-bio";
import { updateCreatorBioSchema } from "../../domain/schemas/creator-profile.schema";
type State = { error: string | null };
export async function updateCreatorBioAction(
_prev: State,
formData: FormData
): Promise<State> {
// 1. auth
const session = await auth();
if (!session?.user?.id) return { error: "Unauthorized." };
// 2. validation at the boundary
const parsed = updateCreatorBioSchema.safeParse({
bio: formData.get("bio"),
website: formData.get("website") || undefined,
});
if (!parsed.success) return { error: parsed.error.issues[0].message };
// 3. wire up infrastructure
await initModules();
const { collection } = await getMongoCollection("bg_creator_profiles");
const repo = new MongoCreatorProfileRepository(collection);
const eventBus = getSharedEventBus();
const useCase = new UpdateCreatorBio(repo, eventBus);
// 4. execute
try {
await useCase.execute({
userId: session.user.id,
...parsed.data,
});
} catch (err) {
if (err instanceof ProfileNotFoundError) return { error: "Profile not found." };
return { error: "An unexpected error occurred." };
}
// 5. cache invalidation
revalidatePath("/s/beautyglam");
revalidatePath("/s/beautyglam/profile");
return { error: null };
}
Each layer handles what it knows about. The action knows about HTTP, authentication, and caching. The use case knows about domain rules. The repository knows about MongoDB. None of them knows about the others' internals. When I refactored the creator profile schema last month, I touched the domain schema file and the action's validation -- the use case and repository didn't change. The boundary held.
Gotcha: When an action starts growing past ~40 lines, the logic bleeding into it probably belongs in a use case. The action should be glue code -- auth, validation, wiring, cache invalidation. If you find yourself writing if/else chains or database queries directly in the action, extract a use case.