Introduction
Before Server Actions, handling a form in Next.js meant writing a client component with useState, wiring up onSubmit, creating a separate /api/contact route, calling it with fetch, and managing loading and error states yourself.
Next.js Server Actions simplify this workflow considerably. You write one async function marked with "use server", pass it directly to your form's action prop, and Next.js handles the form submission automatically. No API route. No manual fetch call. Much less boilerplate.
In this guide, you'll build a complete contact form with server-side validation, database persistence, loading states, and success feedback using Server Actions.
What Is a Server Action?
A Server Action is an async function that runs on the server and is marked with the "use server" directive.
When a form is submitted, Next.js automatically serializes the FormData and invokes the server function.
"use server";
export async function myAction(formData: FormData) {
const name = formData.get("name");
// Runs on the server
// Has access to your database, environment variables, and file system
}You can also place "use server" at the top of an entire file, making every exported function in that file a Server Action.
Project Setup
Create a new project using the App Router:
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-appStep 1: Create the Server Action
Create src/actions/contact.ts:
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export type ContactState =
| {
success: boolean;
error?: string;
}
| null;
export async function submitContact(
prevState: ContactState,
formData: FormData
): Promise<ContactState> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Server-side validation
if (!name || name.trim().length < 2) {
return {
success: false,
error: "Name must be at least 2 characters.",
};
}
if (!email || !email.includes("@")) {
return {
success: false,
error: "Please enter a valid email address.",
};
}
if (!message || message.trim().length < 10) {
return {
success: false,
error: "Message must be at least 10 characters.",
};
}
try {
await db.contactMessage.create({
data: {
name: name.trim(),
email: email.trim().toLowerCase(),
message: message.trim(),
},
});
revalidatePath("/admin/messages");
return { success: true };
} catch (err) {
console.error("Contact form error:", err);
return {
success: false,
error: "Something went wrong. Please try again.",
};
}
}What's happening?
- The function follows the
useActionStatesignature:(prevState, formData). - Validation happens entirely on the server.
revalidatePath()refreshes the admin page after a successful submission.
Step 2: Create the Submit Button
Create src/components/SubmitButton.tsx:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-black text-white py-3 px-6 rounded-lg font-medium
disabled:opacity-60 disabled:cursor-not-allowed
hover:bg-gray-800 transition-colors"
>
{pending ? "Sending..." : "Send Message"}
</button>
);
}useFormStatus() provides the pending state for the parent form action, making it easy to display loading feedback while the server action is running.
Step 3: Build the Contact Form
Create src/components/ContactForm.tsx:
"use client";
import { useActionState } from "react";
import { submitContact, ContactState } from "@/actions/contact";
import { SubmitButton } from "@/components/SubmitButton";
const initialState: ContactState = null;
export function ContactForm() {
const [state, action] = useActionState(submitContact, initialState);
if (state?.success) {
return (
<div className="text-center py-12">
<div className="text-4xl mb-4">✅</div>
<h3 className="text-xl font-semibold mb-2">
Message sent!
</h3>
<p className="text-gray-500">
Thanks for reaching out. I'll get back to you within 24 hours.
</p>
</div>
);
}
return (
<form action={action} className="flex flex-col gap-5">
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{state.error}
</div>
)}
<div className="flex flex-col gap-1.5">
<label
htmlFor="name"
className="text-sm font-medium text-gray-700"
>
Name <span className="text-red-500">*</span>
</label>
<input
id="name"
name="name"
type="text"
placeholder="Your full name"
required
className="border border-gray-300 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-black
focus:border-transparent"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="email"
className="text-sm font-medium text-gray-700"
>
Email <span className="text-red-500">*</span>
</label>
<input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
className="border border-gray-300 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-black
focus:border-transparent"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="message"
className="text-sm font-medium text-gray-700"
>
Message <span className="text-red-500">*</span>
</label>
<textarea
id="message"
name="message"
rows={5}
required
placeholder="Tell me about your project..."
className="border border-gray-300 rounded-lg px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-black
focus:border-transparent resize-none"
/>
</div>
<SubmitButton />
</form>
);
}Step 4: Use the Form
// app/contact/page.tsx
import { ContactForm } from "@/components/ContactForm";
export const metadata = {
title: "Contact",
description: "Get in touch.",
};
export default function ContactPage() {
return (
<main className="max-w-lg mx-auto px-4 py-16">
<h1 className="text-3xl font-bold mb-2">
Get in touch
</h1>
<p className="text-gray-500 mb-8">
Have a project in mind? I'd love to hear about it.
</p>
<ContactForm />
</main>
);
}How It Works
When the form is submitted:
- The user clicks Send Message.
useFormStatus()setspendingtotrue, disabling the button and displaying Sending....- Next.js serializes the
FormData. - The
submitContact()Server Action executes on the server. - The data is validated and stored in the database.
- The returned state updates
useActionState(). - The UI either displays a success message or an error banner.
No API route. No manual fetch() call. No client-side request handling.
Progressive Enhancement
One of the biggest advantages of Server Actions is that they continue to work even if JavaScript is disabled.
Because the form uses the browser's native form submission mechanism, the submission still reaches the server. Interactive enhancements such as loading indicators and success messages require JavaScript, but the form itself remains functional.
This provides a more resilient and accessible user experience.
Why Use Server Actions Instead of API Routes?
- Less boilerplate — no API route,
fetch(), or JSON parsing. - End-to-end type safety — the action's return type is shared directly with
useActionState(). - Server-side validation — validation cannot be bypassed by disabling JavaScript.
- Direct database access — interact with your database without making an HTTP request to your own backend.
- Progressive enhancement — forms continue to work without JavaScript.
- Co-located logic — keep form handling close to the components that use it.
Conclusion
Server Actions significantly simplify form handling in modern Next.js applications. For contact forms, newsletter subscriptions, settings pages, and similar workflows, they eliminate much of the traditional boilerplate while preserving type safety and a good user experience.
A typical setup consists of:
- One
"use server"action file - One
SubmitButtonusinguseFormStatus() - One form component using
useActionState()
With those pieces in place, you can build fully functional forms without creating API routes or manually managing network requests.
