Next.js Server Actions Advanced Patterns: Progressive Enhancement, Optimistic Updates, and Error Handling
Advanced Next.js Server Actions patterns for production. Covers progressive enhancement with useFormState, optimistic updates with useOptimistic, structured error returns, revalidation after mutations, file uploads in Server Actions, and rate limiting.
Server Actions became stable in Next.js 14, but most teams use them only for the simplest cases: a form that calls a server function. The production patterns β progressive enhancement without JavaScript, optimistic updates that feel instant, structured error returns with field-level validation, and rate limiting β require a more deliberate approach.
Typed Action Result Pattern
// lib/actions/types.ts
// Every Server Action returns this union type β never throws to the client
export type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
// Helper constructors
export const ok = <T>(data: T): ActionResult<T> => ({ success: true, data });
export const err = (
error: string,
fieldErrors?: Record<string, string[]>
): ActionResult<never> => ({ success: false, error, fieldErrors });
Server Action with Zod Validation
// app/actions/projects.ts
"use server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth/server";
import { revalidatePath } from "next/cache";
import { ok, err, type ActionResult } from "@/lib/actions/types";
const CreateProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).optional(),
color: z.enum(["blue", "green", "red", "yellow", "purple"]),
});
type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export async function createProject(
input: CreateProjectInput
): Promise<ActionResult<{ id: string; name: string }>> {
// 1. Auth
const session = await requireAuth();
// 2. Validate
const parsed = CreateProjectSchema.safeParse(input);
if (!parsed.success) {
return err("Validation failed", parsed.error.flatten().fieldErrors);
}
// 3. Rate limit check (Redis-based)
const canProceed = await checkRateLimit(`create-project:${session.workspaceId}`, 10, 3600);
if (!canProceed) {
return err("Rate limit exceeded. You can create up to 10 projects per hour.");
}
// 4. Business logic
try {
const project = await prisma.project.create({
data: {
name: parsed.data.name,
description: parsed.data.description,
color: parsed.data.color,
workspaceId: session.workspaceId,
createdById: session.userId,
},
select: { id: true, name: true },
});
// 5. Invalidate affected pages
revalidatePath("/dashboard/projects");
revalidatePath(`/dashboard`);
return ok(project);
} catch (e) {
console.error("[createProject] DB error:", e);
return err("Failed to create project. Please try again.");
}
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
useFormState: Progressive Enhancement
// Works with and without JavaScript β critical for forms on slow connections
// components/create-project-form.tsx
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { createProjectFromFormData } from "@/app/actions/projects-form";
// Separate Server Action for FormData (for progressive enhancement)
// app/actions/projects-form.ts
// "use server";
// export async function createProjectFromFormData(
// prevState: ActionResult | null,
// formData: FormData
// ): Promise<ActionResult> {
// const input = {
// name: formData.get("name") as string,
// color: formData.get("color") as string,
// };
// return createProject(input);
// }
const initialState = null;
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-60"
>
{pending ? "Creatingβ¦" : "Create Project"}
</button>
);
}
export function CreateProjectForm() {
const [state, formAction] = useFormState(createProjectFromFormData, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">Project name</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 w-full border rounded-lg px-3 py-2"
aria-describedby={state?.success === false ? "name-error" : undefined}
/>
{state?.success === false && state.fieldErrors?.name && (
<p id="name-error" className="text-sm text-red-600 mt-1">
{state.fieldErrors.name[0]}
</p>
)}
</div>
<div>
<label htmlFor="color" className="block text-sm font-medium">Color</label>
<select id="color" name="color" className="mt-1 w-full border rounded-lg px-3 py-2">
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="red">Red</option>
</select>
</div>
{/* Global error (not field-specific) */}
{state?.success === false && !state.fieldErrors && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">
{state.error}
</p>
)}
{/* Success message */}
{state?.success === true && (
<p className="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg">
Project created successfully!
</p>
)}
<SubmitButton />
</form>
);
}
useOptimistic: Instant Feedback
// components/task-list.tsx β tasks appear instantly before server confirms
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTaskComplete } from "@/app/actions/tasks";
interface Task {
id: string;
title: string;
completed: boolean;
}
interface TaskListProps {
tasks: Task[];
}
export function TaskList({ tasks }: TaskListProps) {
const [optimisticTasks, addOptimistic] = useOptimistic(
tasks,
// Reducer: apply optimistic state
(state: Task[], { id, completed }: { id: string; completed: boolean }) =>
state.map((t) => (t.id === id ? { ...t, completed } : t))
);
const [, startTransition] = useTransition();
async function handleToggle(task: Task) {
const newCompleted = !task.completed;
startTransition(async () => {
// 1. Optimistic update (instant)
addOptimistic({ id: task.id, completed: newCompleted });
// 2. Actual server call (may take 200β500ms)
const result = await toggleTaskComplete(task.id, newCompleted);
if (!result.success) {
// React will revert the optimistic state automatically
// when the transition settles with the real server state
console.error("[toggleTask] Failed:", result.error);
}
});
}
return (
<ul className="space-y-2">
{optimisticTasks.map((task) => (
<li key={task.id} className="flex items-center gap-3">
<button
onClick={() => handleToggle(task)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center
${task.completed ? "bg-blue-600 border-blue-600" : "border-gray-300"}`}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
>
{task.completed && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
)}
</button>
<span className={task.completed ? "line-through text-gray-400" : ""}>
{task.title}
</span>
</li>
))}
</ul>
);
}
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
File Uploads in Server Actions
// app/actions/uploads.ts
"use server";
import { writeFile } from "fs/promises";
import { join } from "path";
import { requireAuth } from "@/lib/auth/server";
import { ok, err, type ActionResult } from "@/lib/actions/types";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION! });
export async function uploadAvatar(
formData: FormData
): Promise<ActionResult<{ url: string }>> {
const session = await requireAuth();
const file = formData.get("avatar") as File | null;
if (!file) return err("No file provided");
// Validate
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
if (!ALLOWED_TYPES.includes(file.type)) {
return err("Only JPEG, PNG, and WebP images are allowed");
}
if (file.size > MAX_SIZE_BYTES) {
return err("File size must be under 5 MB");
}
const buffer = Buffer.from(await file.arrayBuffer());
const ext = file.type.split("/")[1];
const key = `avatars/${session.userId}-${Date.now()}.${ext}`;
try {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: buffer,
ContentType: file.type,
CacheControl: "max-age=31536000",
}));
const url = `https://${process.env.CDN_DOMAIN}/${key}`;
return ok({ url });
} catch (e) {
console.error("[uploadAvatar] S3 error:", e);
return err("Upload failed. Please try again.");
}
}
Revalidation Patterns
// app/actions/cache-patterns.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
// Pattern 1: Revalidate specific path after mutation
export async function updateProjectName(id: string, name: string) {
await prisma.project.update({ where: { id }, data: { name } });
revalidatePath(`/dashboard/projects/${id}`); // Specific project page
revalidatePath("/dashboard/projects"); // Project list
revalidatePath("/dashboard", "layout"); // Layout (if project name shown in nav)
}
// Pattern 2: Tag-based revalidation (more targeted)
// In your data-fetching function, tag the cache:
// fetch('/api/projects', { next: { tags: ['projects', `project-${id}`] } })
export async function deleteProject(id: string) {
await prisma.project.delete({ where: { id } });
revalidateTag(`project-${id}`); // Any fetch tagged with this
revalidateTag("projects"); // The project list cache
}
// Pattern 3: redirect after form submit (replaces revalidatePath)
import { redirect } from "next/navigation";
export async function createAndRedirect(formData: FormData) {
const project = await prisma.project.create({
data: { name: formData.get("name") as string },
select: { id: true },
});
redirect(`/dashboard/projects/${project.id}`); // Navigates after creation
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Server Actions + Zod validation + typed results | 1 dev | 1β2 days | $400β800 |
| useFormState progressive enhancement forms | 1 dev | 1 day | $300β600 |
| useOptimistic for list mutations | 1 dev | 1 day | $300β600 |
| File upload Server Action (S3 or local) | 1 dev | 1 day | $300β600 |
See Also
- React Hook Form with Zod Validation
- Next.js Server Components Deep Dive
- Next.js File Uploads
- React Multi-Step Form Stepper
- React Optimistic Updates
Working With Viprasol
Server Actions production patterns require discipline: always return a typed result union (never throw), always validate with Zod before touching the database, always use useFormStatus for pending state (not local state), and always fire revalidatePath for the affected pages. The useOptimistic pattern makes list mutations feel instant while the server catches up β React reverts automatically if the server call fails.
What we deliver:
ActionResult<T>union type:{ success: true; data: T }|{ success: false; error; fieldErrors }ok()/err()constructors for clean action returnscreateProject: requireAuth β safeParse β rate limit β Prisma create β revalidatePath β ok()useFormStatewithcreateProjectFromFormData(prevState, formData)signature for progressive enhancementuseFormStatuspending state inSubmitButtonsub-component (can't use in same form component)useOptimisticreducer pattern:addOptimistic()insidestartTransitionβ auto-revert on failureuploadAvatar: FormDatafile.arrayBuffer()β S3PutObjectCommandβ CDN URL- Revalidation:
revalidatePath(path)for path-based,revalidateTag(tag)for tag-based,redirect()for post-submit navigation
Talk to our team about your Next.js forms and mutations β
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.