|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/forms-actions
$cat docs/next.js-forms-&-server-actions.md
updated Recentlyยท40 min readยทpublished

Next.js Forms & Server Actions

โ—†Next.jsโ—†Formsโ—†Server Actionsโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Server Actions Overview

Server Actions are async functions that run on the server and can be called directly from client components. They eliminate the need to create manual API routes for form submissions and data mutations. With Server Actions, you write a function marked with 'use server', and Next.js handles the RPC (Remote Procedure Call) plumbing automatically.

Server Actions provide progressive enhancement by default. Forms work without JavaScript enabled, and the experience improves with JavaScript โ€” faster submissions, optimistic updates, and instant feedback. This is the full-stack solution for mutations in the App Router.

โ„น

info

Server Actions replace the pattern of creating API routes for form submissions. Instead of fetching your own API, you call the server function directly. The browser handles the form submission via a POST request to the action URL, and Next.js processes it on the server.
Basic Usage

Server Actions can be defined in two ways: inline in a Client Component or in a separate file with the 'use server' directive.

Inline Server Action

components/signup-form.tsx
TSX
1"use client";
2
3export function SignupForm() {
4 async function signup(formData: FormData) {
5 "use server";
6
7 const name = formData.get("name") as string;
8 const email = formData.get("email") as string;
9 const password = formData.get("password") as string;
10
11 // Server-side logic
12 const user = await db.user.create({
13 data: { name, email, password: await hash(password) },
14 });
15
16 // Redirect after successful signup
17 redirect(`/dashboard`);
18 }
19
20 return (
21 <form action={signup}>
22 <input name="name" placeholder="Name" required />
23 <input name="email" type="email" placeholder="Email" required />
24 <input name="password" type="password" placeholder="Password" required />
25 <button type="submit">Sign Up</button>
26 </form>
27 );
28}

Separate Server Action File

app/actions/user.ts
TSX
1// app/actions/user.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6import { z } from "zod";
7
8const signupSchema = z.object({
9 name: z.string().min(2, "Name must be at least 2 characters"),
10 email: z.string().email("Invalid email address"),
11 password: z.string().min(8, "Password must be at least 8 characters"),
12});
13
14export async function signup(formData: FormData) {
15 const parsed = signupSchema.safeParse({
16 name: formData.get("name"),
17 email: formData.get("email"),
18 password: formData.get("password"),
19 });
20
21 if (!parsed.success) {
22 return { error: parsed.error.flatten().fieldErrors };
23 }
24
25 const existingUser = await db.user.findUnique({
26 where: { email: parsed.data.email },
27 });
28
29 if (existingUser) {
30 return { error: { email: ["Email already in use"] } };
31 }
32
33 await db.user.create({
34 data: {
35 name: parsed.data.name,
36 email: parsed.data.email,
37 password: await hash(parsed.data.password),
38 },
39 });
40
41 redirect("/dashboard");
42}
Form Validation

Validate form data on the server inside the Server Action. Return error objects that the client can display. This keeps validation logic in one place โ€” the server โ€” where it cannot be bypassed.

app/actions/contact.ts
TSX
1// app/actions/contact.ts
2"use server";
3
4import { z } from "zod";
5
6const contactSchema = z.object({
7 name: z.string().min(1, "Name is required"),
8 email: z.string().email("Invalid email"),
9 subject: z.string().min(5, "Subject must be at least 5 characters"),
10 message: z.string().min(20, "Message must be at least 20 characters"),
11});
12
13export type ContactFormState = {
14 errors?: {
15 name?: string[];
16 email?: string[];
17 subject?: string[];
18 message?: string[];
19 };
20 success?: boolean;
21};
22
23export async function submitContact(
24 prevState: ContactFormState,
25 formData: FormData
26): Promise<ContactFormState> {
27 const parsed = contactSchema.safeParse({
28 name: formData.get("name"),
29 email: formData.get("email"),
30 subject: formData.get("subject"),
31 message: formData.get("message"),
32 });
33
34 if (!parsed.success) {
35 return { errors: parsed.error.flatten().fieldErrors };
36 }
37
38 await sendEmail(parsed.data);
39 return { success: true };
40}
components/contact-form.tsx
TSX
1"use client";
2
3import { useActionState } from "react";
4import { submitContact, type ContactFormState } from "@/app/actions/contact";
5
6export function ContactForm() {
7 const [state, formAction, isPending] = useActionState(
8 submitContact,
9 {} as ContactFormState
10 );
11
12 return (
13 <form action={formAction} className="space-y-4">
14 <div>
15 <input name="name" placeholder="Name" className="w-full border rounded p-2" />
16 {state.errors?.name && (
17 <p className="text-red-400 text-xs mt-1">{state.errors.name[0]}</p>
18 )}
19 </div>
20
21 <div>
22 <input name="email" type="email" placeholder="Email" className="w-full border rounded p-2" />
23 {state.errors?.email && (
24 <p className="text-red-400 text-xs mt-1">{state.errors.email[0]}</p>
25 )}
26 </div>
27
28 <div>
29 <input name="subject" placeholder="Subject" className="w-full border rounded p-2" />
30 {state.errors?.subject && (
31 <p className="text-red-400 text-xs mt-1">{state.errors.subject[0]}</p>
32 )}
33 </div>
34
35 <div>
36 <textarea name="message" placeholder="Message" rows={5} className="w-full border rounded p-2" />
37 {state.errors?.message && (
38 <p className="text-red-400 text-xs mt-1">{state.errors.message[0]}</p>
39 )}
40 </div>
41
42 <button
43 type="submit"
44 disabled={isPending}
45 className="w-full bg-[#00FF41] text-black py-2 rounded font-mono"
46 >
47 {isPending ? "Sending..." : "Send Message"}
48 </button>
49
50 {state.success && (
51 <p className="text-[#00FF41] text-sm">Message sent successfully!</p>
52 )}
53 </form>
54 );
55}
โ„น

info

useActionState (previously useFormState) manages the form state across submissions. The first argument is the Server Action, the second is the initial state, and it returns the current state, the action to pass to form action, and a pending boolean.
Progressive Enhancement

Forms with Server Actions work without JavaScript. The browser submits the form as a standard POST request. When JavaScript loads, the form is enhanced to submit via AJAX for a better experience. This is progressive enhancement โ€” the form works for everyone and improves for users with JavaScript.

components/basic-form.tsx
TSX
1// This form works WITHOUT JavaScript
2// The browser handles the form submission natively
3// When JS loads, it becomes an AJAX submission
4
5export function BasicForm() {
6 async function handleSubmit(formData: FormData) {
7 "use server";
8 const name = formData.get("name") as string;
9 await saveToDatabase(name);
10 redirect("/success");
11 }
12
13 return (
14 <form action={handleSubmit}>
15 <input name="name" required />
16 <button type="submit">Submit</button>
17 </form>
18 );
19}
โœ“

best practice

Progressive enhancement is one of the strongest benefits of Server Actions. Even if your JavaScript fails to load or is blocked, forms still work. This makes your application more resilient and accessible.
Optimistic Updates

Use useOptimistic to immediately update the UI when a user performs an action, before the server confirms the result. If the server action fails, the UI reverts to the previous state.

components/post-card.tsx
TSX
1"use client";
2
3import { useOptimistic } from "react";
4import { toggleLike } from "@/app/actions/post";
5
6type Post = { id: string; title: string; likes: number; isLiked: boolean };
7
8export function PostCard({ post }: { post: Post }) {
9 const [optimisticPost, addOptimisticLike] = useOptimistic(
10 post,
11 (current, delta: number) => ({
12 ...current,
13 likes: current.likes + delta,
14 isLiked: delta > 0 ? true : current.isLiked,
15 })
16 );
17
18 async function handleLike() {
19 addOptimisticLike(1); // Immediately update UI
20 await toggleLike(post.id); // Server action
21 }
22
23 return (
24 <div className="border rounded p-4">
25 <h3>{optimisticPost.title}</h3>
26 <button onClick={handleLike} className="flex items-center gap-2">
27 <span>{optimisticPost.isLiked ? "โค๏ธ" : "๐Ÿค"}</span>
28 <span>{optimisticPost.likes}</span>
29 </button>
30 </div>
31 );
32}
useFormStatus

The useFormStatus hook provides pending state and other form information. It must be called from a component rendered inside a <form> element.

components/post-form.tsx
TSX
1"use client";
2
3import { useFormStatus } from "react-dom";
4import { submitPost } from "@/app/actions/post";
5
6function SubmitButton() {
7 const { pending, data, method, action } = useFormStatus();
8
9 return (
10 <button
11 type="submit"
12 disabled={pending}
13 className="px-4 py-2 bg-[#00FF41] text-black rounded font-mono disabled:opacity-50"
14 >
15 {pending ? (
16 <span className="flex items-center gap-2">
17 <span className="animate-spin">โŸณ</span> Submitting...
18 </span>
19 ) : (
20 "Submit Post"
21 )}
22 </button>
23 );
24}
25
26export function PostForm() {
27 return (
28 <form action={submitPost}>
29 <input name="title" placeholder="Title" required />
30 <textarea name="content" placeholder="Content" required />
31 <SubmitButton /> {/* Must be inside the form */}
32 </form>
33 );
34}
โš 

warning

useFormStatus only works in components that are children of a <form> element. If called outside a form context, it returns an empty object. Create a separate SubmitButton component and place it inside the form.
File Uploads with Forms

Server Actions handle file uploads via FormData. Use enctype="multipart/form-data" on the form element.

components/file-upload-form.tsx
TSX
1"use client";
2
3import { useRef } from "react";
4import { uploadFile } from "@/app/actions/upload";
5
6export function FileUploadForm() {
7 const formRef = useRef<HTMLFormElement>(null);
8
9 async function handleUpload(formData: FormData) {
10 const result = await uploadFile(formData);
11 if (result.success) {
12 formRef.current?.reset();
13 }
14 }
15
16 return (
17 <form ref={formRef} action={handleUpload} encType="multipart/form-data">
18 <input
19 type="file"
20 name="file"
21 accept="image/*"
22 required
23 className="block w-full text-sm text-gray-400
24 file:mr-4 file:py-2 file:px-4
25 file:rounded file:border-0
26 file:text-sm file:font-mono
27 file:bg-[#00FF41]/10 file:text-[#00FF41]
28 hover:file:bg-[#00FF41]/20"
29 />
30 <button
31 type="submit"
32 className="mt-4 px-4 py-2 bg-[#00FF41] text-black rounded text-sm font-mono"
33 >
34 Upload
35 </button>
36 </form>
37 );
38}
app/actions/upload.ts
TSX
1// app/actions/upload.ts
2"use server";
3
4import { put } from "@vercel/blob";
5import { revalidatePath } from "next/cache";
6
7export async function uploadFile(formData: FormData) {
8 const file = formData.get("file") as File | null;
9 if (!file) return { success: false, error: "No file provided" };
10
11 // Upload to Vercel Blob (or any storage service)
12 const blob = await put(file.name, file, {
13 access: "public",
14 contentType: file.type,
15 });
16
17 // Save to database
18 await db.file.create({
19 data: {
20 name: file.name,
21 url: blob.url,
22 size: file.size,
23 type: file.type,
24 },
25 });
26
27 revalidatePath("/files");
28 return { success: true, url: blob.url };
29}
Multi-Step Forms

Build multi-step forms by combining Server Actions with client state. Each step submits data to the server and the final step processes everything.

components/onboarding-wizard.tsx
TSX
1"use client";
2
3import { useState } from "react";
4import { submitOnboarding } from "@/app/actions/onboarding";
5
6export function OnboardingWizard() {
7 const [step, setStep] = useState(1);
8 const [formData, setFormData] = useState({
9 name: "", email: "", company: "", role: "",
10 });
11
12 function updateField(field: string, value: string) {
13 setFormData(prev => ({ ...prev, [field]: value }));
14 }
15
16 async function handleSubmit() {
17 const result = await submitOnboarding(formData);
18 if (result.success) {
19 window.location.href = "/dashboard";
20 }
21 }
22
23 return (
24 <div className="max-w-md mx-auto">
25 {/* Progress indicator */}
26 <div className="flex gap-2 mb-8">
27 {[1, 2, 3].map(s => (
28 <div
29 key={s}
30 className={`h-1 flex-1 rounded ${
31 s <= step ? "bg-[#00FF41]" : "bg-gray-700"
32 }`}
33 />
34 ))}
35 </div>
36
37 {step === 1 && (
38 <div className="space-y-4">
39 <h2 className="text-xl font-mono">Personal Info</h2>
40 <input
41 name="name"
42 placeholder="Full Name"
43 value={formData.name}
44 onChange={e => updateField("name", e.target.value)}
45 className="w-full border rounded p-2"
46 />
47 <input
48 name="email"
49 type="email"
50 placeholder="Email"
51 value={formData.email}
52 onChange={e => updateField("email", e.target.value)}
53 className="w-full border rounded p-2"
54 />
55 <button onClick={() => setStep(2)} className="w-full bg-[#00FF41] text-black py-2 rounded">
56 Next
57 </button>
58 </div>
59 )}
60
61 {step === 2 && (
62 <div className="space-y-4">
63 <h2 className="text-xl font-mono">Work Info</h2>
64 <input
65 name="company"
66 placeholder="Company"
67 value={formData.company}
68 onChange={e => updateField("company", e.target.value)}
69 className="w-full border rounded p-2"
70 />
71 <input
72 name="role"
73 placeholder="Role"
74 value={formData.role}
75 onChange={e => updateField("role", e.target.value)}
76 className="w-full border rounded p-2"
77 />
78 <div className="flex gap-4">
79 <button onClick={() => setStep(1)} className="flex-1 border rounded py-2">Back</button>
80 <button onClick={() => setStep(3)} className="flex-1 bg-[#00FF41] text-black py-2 rounded">Next</button>
81 </div>
82 </div>
83 )}
84
85 {step === 3 && (
86 <div className="space-y-4">
87 <h2 className="text-xl font-mono">Review</h2>
88 <div className="border rounded p-4 space-y-2">
89 <p className="text-sm">Name: {formData.name}</p>
90 <p className="text-sm">Email: {formData.email}</p>
91 <p className="text-sm">Company: {formData.company}</p>
92 <p className="text-sm">Role: {formData.role}</p>
93 </div>
94 <div className="flex gap-4">
95 <button onClick={() => setStep(2)} className="flex-1 border rounded py-2">Back</button>
96 <button onClick={handleSubmit} className="flex-1 bg-[#00FF41] text-black py-2 rounded">Submit</button>
97 </div>
98 </div>
99 )}
100 </div>
101 );
102}
Error Handling

Handle errors in Server Actions by returning error objects. For unexpected errors, throw to trigger the nearest error boundary.

app/actions/order.ts
TSX
1// app/actions/order.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6
7export type OrderState = {
8 error?: string;
9 success?: boolean;
10};
11
12export async function placeOrder(
13 prevState: OrderState,
14 formData: FormData
15): Promise<OrderState> {
16 try {
17 const cartId = formData.get("cartId") as string;
18 const items = await db.cartItem.findMany({
19 where: { cartId },
20 include: { product: true },
21 });
22
23 if (items.length === 0) {
24 return { error: "Cart is empty" };
25 }
26
27 // Process payment
28 const payment = await processPayment(items);
29 if (!payment.success) {
30 return { error: `Payment failed: ${payment.message}` };
31 }
32
33 // Create order
34 await db.order.create({
35 data: {
36 items: items.map(i => ({
37 productId: i.productId,
38 quantity: i.quantity,
39 price: i.product.price,
40 })),
41 total: items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
42 },
43 });
44
45 // Clear cart
46 await db.cartItem.deleteMany({ where: { cartId } });
47 revalidatePath("/cart");
48 redirect("/orders/success");
49 } catch (error) {
50 // Unexpected error โ€” will trigger error boundary
51 console.error("Order placement failed:", error);
52 return { error: "An unexpected error occurred. Please try again." };
53 }
54}
โ„น

info

Return structured errors from Server Actions instead of throwing. This lets the client display specific error messages next to the relevant form fields. Only throw for truly unexpected errors that should trigger the error boundary.
Revalidation After Mutations

After performing a mutation, call revalidatePath or revalidateTag to refresh the cached data. This ensures the UI shows the latest data after a successful action.

app/actions/todo.ts
TSX
1// app/actions/todo.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { db } from "@/lib/database";
6
7export async function addTodo(formData: FormData) {
8 const text = formData.get("text") as string;
9 if (!text.trim()) return;
10
11 await db.todo.create({
12 data: { text, completed: false },
13 });
14
15 // Refresh the /todos page to show the new todo
16 revalidatePath("/todos");
17}
18
19export async function toggleTodo(id: string) {
20 const todo = await db.todo.findUnique({ where: { id } });
21 if (!todo) return;
22
23 await db.todo.update({
24 where: { id },
25 data: { completed: !todo.completed },
26 });
27
28 revalidatePath("/todos");
29}
30
31export async function deleteTodo(id: string) {
32 await db.todo.delete({ where: { id } });
33 revalidatePath("/todos");
34}
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXTJS-06ยทRevision: 1.0