Next.js Forms & Server Actions
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 can be defined in two ways: inline in a Client Component or in a separate file with the 'use server' directive.
Inline Server Action
| 1 | "use client"; |
| 2 | |
| 3 | export 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
| 1 | // app/actions/user.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { redirect } from "next/navigation"; |
| 6 | import { z } from "zod"; |
| 7 | |
| 8 | const 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 | |
| 14 | export 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 | } |
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.
| 1 | // app/actions/contact.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { z } from "zod"; |
| 5 | |
| 6 | const 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 | |
| 13 | export type ContactFormState = { |
| 14 | errors?: { |
| 15 | name?: string[]; |
| 16 | email?: string[]; |
| 17 | subject?: string[]; |
| 18 | message?: string[]; |
| 19 | }; |
| 20 | success?: boolean; |
| 21 | }; |
| 22 | |
| 23 | export 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 | } |
| 1 | "use client"; |
| 2 | |
| 3 | import { useActionState } from "react"; |
| 4 | import { submitContact, type ContactFormState } from "@/app/actions/contact"; |
| 5 | |
| 6 | export 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
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.
| 1 | // This form works WITHOUT JavaScript |
| 2 | // The browser handles the form submission natively |
| 3 | // When JS loads, it becomes an AJAX submission |
| 4 | |
| 5 | export 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
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.
| 1 | "use client"; |
| 2 | |
| 3 | import { useOptimistic } from "react"; |
| 4 | import { toggleLike } from "@/app/actions/post"; |
| 5 | |
| 6 | type Post = { id: string; title: string; likes: number; isLiked: boolean }; |
| 7 | |
| 8 | export 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 | } |
The useFormStatus hook provides pending state and other form information. It must be called from a component rendered inside a <form> element.
| 1 | "use client"; |
| 2 | |
| 3 | import { useFormStatus } from "react-dom"; |
| 4 | import { submitPost } from "@/app/actions/post"; |
| 5 | |
| 6 | function 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 | |
| 26 | export 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
Server Actions handle file uploads via FormData. Use enctype="multipart/form-data" on the form element.
| 1 | "use client"; |
| 2 | |
| 3 | import { useRef } from "react"; |
| 4 | import { uploadFile } from "@/app/actions/upload"; |
| 5 | |
| 6 | export 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 | } |
| 1 | // app/actions/upload.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { put } from "@vercel/blob"; |
| 5 | import { revalidatePath } from "next/cache"; |
| 6 | |
| 7 | export 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 | } |
Build multi-step forms by combining Server Actions with client state. Each step submits data to the server and the final step processes everything.
| 1 | "use client"; |
| 2 | |
| 3 | import { useState } from "react"; |
| 4 | import { submitOnboarding } from "@/app/actions/onboarding"; |
| 5 | |
| 6 | export 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 | } |
Handle errors in Server Actions by returning error objects. For unexpected errors, throw to trigger the nearest error boundary.
| 1 | // app/actions/order.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { redirect } from "next/navigation"; |
| 6 | |
| 7 | export type OrderState = { |
| 8 | error?: string; |
| 9 | success?: boolean; |
| 10 | }; |
| 11 | |
| 12 | export 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
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.
| 1 | // app/actions/todo.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { db } from "@/lib/database"; |
| 6 | |
| 7 | export 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 | |
| 19 | export 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 | |
| 31 | export async function deleteTodo(id: string) { |
| 32 | await db.todo.delete({ where: { id } }); |
| 33 | revalidatePath("/todos"); |
| 34 | } |