React Server Actions: Form Handling, Optimistic UI, and Progressive Enhancement
Master React Server Actions in Next.js: handle forms without API routes, implement optimistic UI with useOptimistic, add progressive enhancement, validate with Zod, and handle errors correctly.
React Server Actions let you run server-side code directly from a form submit or button click โ no API route required. The code executes on the server, the client gets the result, and you never expose implementation details in the browser.
Beyond convenience, Server Actions enable progressive enhancement: forms work without JavaScript, and enhance to optimistic UI when JavaScript is available. That's the same pattern native HTML forms have always used, but with React's component model.
Basic Server Action
// src/app/projects/actions.ts
"use server"; // Everything in this file runs on the server
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/db";
import { getUserFromSession } from "@/lib/auth";
const createProjectSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(100),
description: z.string().max(500).optional(),
visibility: z.enum(["private", "team", "public"]).default("private"),
});
export type ActionState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
data?: { projectId: string };
};
export async function createProject(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. Authenticate
const user = await getUserFromSession();
if (!user) {
return { success: false, message: "You must be logged in" };
}
// 2. Parse and validate
const rawData = {
name: formData.get("name"),
description: formData.get("description"),
visibility: formData.get("visibility"),
};
const parsed = createProjectSchema.safeParse(rawData);
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
};
}
// 3. Business logic
const { data } = parsed;
try {
const { rows } = await db.query<{ id: string }>(
`INSERT INTO projects (name, description, visibility, owner_id, tenant_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[data.name, data.description, data.visibility, user.id, user.tenantId]
);
const projectId = rows[0].id;
// 4. Revalidate cached data
revalidatePath("/projects");
// 5. Return success (or redirect)
return { success: true, data: { projectId } };
} catch (error) {
console.error("Failed to create project:", error);
return { success: false, message: "Failed to create project. Please try again." };
}
}
useActionState for Form State
useActionState (formerly useFormState) manages the state from a Server Action:
// src/app/projects/new/page.tsx
"use client";
import { useActionState } from "react";
import { createProject, type ActionState } from "../actions";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
const initialState: ActionState = { success: false };
export default function NewProjectPage() {
const router = useRouter();
const [state, formAction, isPending] = useActionState(
createProject,
initialState
);
// Redirect on success
useEffect(() => {
if (state.success && state.data?.projectId) {
router.push(`/projects/${state.data.projectId}`);
}
}, [state, router]);
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 block w-full rounded border px-3 py-2 ${
state.errors?.name ? "border-red-500" : "border-gray-300"
}`}
aria-describedby={state.errors?.name ? "name-error" : undefined}
/>
{state.errors?.name && (
<p id="name-error" className="mt-1 text-sm text-red-600">
{state.errors.name[0]}
</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium">
Description (optional)
</label>
<textarea
id="description"
name="description"
rows={3}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="visibility" className="block text-sm font-medium">
Visibility
</label>
<select
id="visibility"
name="visibility"
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
>
<option value="private">Private</option>
<option value="team">Team</option>
<option value="public">Public</option>
</select>
</div>
{/* General error message */}
{!state.success && state.message && !state.errors && (
<p className="text-sm text-red-600">{state.message}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}
๐ 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
Optimistic UI with useOptimistic
useOptimistic updates the UI immediately before the server responds, then reconciles:
// src/components/TodoList.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/todos/actions";
interface Todo {
id: string;
title: string;
completed: boolean;
isPending?: boolean;
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition();
// Optimistic state โ shows expected state before server confirms
const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
initialTodos,
(currentTodos: Todo[], action: { type: "toggle" | "delete"; id: string }) => {
if (action.type === "toggle") {
return currentTodos.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed, isPending: true }
: todo
);
}
if (action.type === "delete") {
return currentTodos.filter((todo) => todo.id !== action.id);
}
return currentTodos;
}
);
function handleToggle(todoId: string) {
startTransition(async () => {
// Update UI immediately
updateOptimisticTodos({ type: "toggle", id: todoId });
// Then run Server Action (may fail โ UI will revert on error)
await toggleTodo(todoId);
});
}
function handleDelete(todoId: string) {
startTransition(async () => {
updateOptimisticTodos({ type: "delete", id: todoId });
await deleteTodo(todoId);
});
}
return (
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={`flex items-center gap-3 ${todo.isPending ? "opacity-70" : ""}`}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
className="h-4 w-4"
/>
<span
className={todo.completed ? "line-through text-gray-400" : ""}
>
{todo.title}
</span>
{todo.isPending && (
<span className="text-xs text-gray-400">Saving...</span>
)}
<button
onClick={() => handleDelete(todo.id)}
className="ml-auto text-red-500 text-sm"
>
Delete
</button>
</li>
))}
</ul>
);
}
Progressive Enhancement
Server Actions work without JavaScript โ the form submits naturally. Add JavaScript for better UX:
// src/components/SubscribeForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions";
// This form works with AND without JavaScript:
// Without JS: Standard HTML form POST, page reloads with success/error
// With JS: useActionState intercepts, no page reload, better UX
const initialState = { success: false, message: "" };
export function SubscribeForm() {
const [state, formAction, isPending] = useActionState(
subscribeToNewsletter,
initialState
);
if (state.success) {
return (
<div className="text-green-600 font-medium">
โ {state.message ?? "You're subscribed!"}
</div>
);
}
return (
<form action={formAction} className="flex gap-2">
<input
type="email"
name="email"
placeholder="you@example.com"
required
className="flex-1 rounded border px-3 py-2"
/>
{/* useFormStatus in a submit button gives pending state */}
<SubmitButton isPending={isPending} />
{state.message && !state.success && (
<p className="text-sm text-red-600 mt-1">{state.message}</p>
)}
</form>
);
}
// Separate component so useFormStatus works correctly
function SubmitButton({ isPending }: { isPending: boolean }) {
return (
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white text-sm disabled:opacity-50"
>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
);
}
๐ 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 Upload with Server Actions
// src/app/profile/actions.ts
"use server";
import { put } from "@vercel/blob";
import sharp from "sharp";
export async function uploadAvatar(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await getUserFromSession();
if (!user) return { success: false, message: "Unauthorized" };
const file = formData.get("avatar") as File | null;
if (!file || file.size === 0) {
return { success: false, errors: { avatar: ["Please select an image"] } };
}
// Validate file type
if (!file.type.startsWith("image/")) {
return { success: false, errors: { avatar: ["File must be an image"] } };
}
// Validate size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
return { success: false, errors: { avatar: ["Image must be under 5MB"] } };
}
// Process: resize and convert to WebP
const buffer = Buffer.from(await file.arrayBuffer());
const processed = await sharp(buffer)
.resize(200, 200, { fit: "cover", position: "center" })
.webp({ quality: 85 })
.toBuffer();
// Upload to blob storage
const { url } = await put(
`avatars/${user.id}.webp`,
processed,
{
access: "public",
contentType: "image/webp",
addRandomSuffix: false, // Deterministic URL โ overwrites previous avatar
}
);
// Update user record
await db.query(
"UPDATE users SET avatar_url = $1 WHERE id = $2",
[url, user.id]
);
revalidatePath("/profile");
return { success: true, message: "Avatar updated successfully" };
}
Error Handling Patterns
// src/app/actions.ts
"use server";
// Pattern 1: Return error state (for form validation)
export async function updateProfile(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
try {
// ... action logic
return { success: true };
} catch (error) {
// Don't throw from Server Actions โ return error state
// Throwing would cause an unhandled error boundary
console.error(error);
return { success: false, message: "Something went wrong. Please try again." };
}
}
// Pattern 2: Throw for redirects and permanent failures
// (Next.js redirect() internally throws โ don't catch it)
export async function deleteAccount(formData: FormData) {
const user = await getUserFromSession();
if (!user) throw new Error("Unauthorized"); // Shows error boundary
await permanentlyDeleteUser(user.id);
redirect("/goodbye"); // Throws internally โ must not be in try/catch
}
// Pattern 3: Revalidate after mutations
export async function updateProjectName(
projectId: string,
name: string
): Promise<void> {
// Can be called directly (not via form) โ no FormData needed
await db.query("UPDATE projects SET name = $1 WHERE id = $2", [name, projectId]);
// Revalidate specific paths
revalidatePath(`/projects/${projectId}`);
revalidatePath("/projects");
// Or revalidate by cache tag
revalidateTag("projects");
}
When to Use Server Actions vs API Routes
| Scenario | Server Actions | API Routes |
|---|---|---|
| Form submissions | โ Ideal | Possible but verbose |
| Progressive enhancement | โ Built-in | โ Manual |
| Optimistic UI | โ useOptimistic | Possible via React Query |
| Third-party API consumption | โ Fine | โ Fine |
| Webhook endpoint | โ Can't set headers | โ Required |
| Streaming response | โ Limited | โ Better |
| Mobile app API | โ Not appropriate | โ Required |
| File download | โ | โ Required |
| Cross-origin requests | โ | โ CORS support |
See Also
- Next.js App Router Patterns โ App Router architecture
- Next.js Performance Optimization โ PPR and streaming
- React Query Patterns for Production โ client-side state
- TypeScript Testing Patterns โ testing Server Actions
Working With Viprasol
Server Actions simplify the full-stack development model significantly โ fewer API routes, built-in progressive enhancement, and type safety from form to database. Our Next.js engineers design Server Action patterns that handle file uploads, optimistic UI, validation, and error recovery correctly from the start.
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.