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, handling loading and error states — and doing all of this every single time you needed a form.
Next.js 15 Server Actions change this completely. You write one async function marked with "use server", pass it directly to your form's action prop, and Next.js handles everything else — serialisation, the network call, error handling. No API route. No fetch. No boilerplate.
In this post I'll walk you through building a complete contact form — validation, database save, loading state, success message — using only Server Actions.
What is a Server Action?
A Server Action is an async function that runs on the server, marked with the "use server" directive at the top. When a form submits, Next.js serialises the FormData and sends it to the server function automatically — without you writing a single line of fetch code.
"use server";
export async function myAction(formData: FormData) {
const name = formData.get("name");
// This runs on the server — has access to DB, env vars, file system
}
You can also place "use server" at the top of an entire file — every exported function in that file becomes a Server Action automatically. This is the pattern I prefer for keeping actions organised.
Project setup
Make sure you have Next.js 15 with the App Router:
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
Step 1: Create the Server Action
Create src/actions/contact.ts. The "use server" directive at the top of the file makes every export a Server Action:
"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 {
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." };
}
}
A few things to notice here:
- The function signature follows the
useActionStatepattern —(prevState, formData) - Validation happens entirely on the server — users can't bypass it by disabling JavaScript
revalidatePathtells Next.js to refresh the admin messages page after a successful submission
Step 2: Create the SubmitButton client component
To show a loading state while the action is running, we need one small client component. Create src/components/SubmitButton.tsx:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
);
}
useFormStatus is a React hook that reads the pending state of the parent form's action. It only works inside a component that is a child of a <form> element — which is why we extract it into its own component rather than putting it directly in the form.
Step 3: Build the Contact Form
Now create src/components/ContactForm.tsx. This is a client component because we need useActionState to track the server action's result:
"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 (
✅
Message sent!
Thanks for reaching out. I'll get back to you within 24 hours.
);
}
return (
);
}
Step 4: Use it on your page
Drop the form into any page — this is a Server Component that renders the client form:
// app/contact/page.tsx
import { ContactForm } from "@/components/ContactForm";
export const metadata = {
title: "Contact | Harsh Patel",
description: "Get in touch for freelance projects and collaborations.",
};
export default function ContactPage() {
return (
Get in touch
Have a project in mind? I'd love to hear about it.
);
}
How it all fits together
Here's the complete flow when a user submits the form:
- User fills in the form and clicks "Send Message"
useFormStatussetspending: true— button shows "Sending..." and is disabled- Next.js serialises the
FormDataand callssubmitContacton the server - The action validates the data, saves to the database, and returns a state object
useActionStatereceives the returned state —pendingbecomesfalse- If
success: true, the form is replaced with a thank-you message - If
error, the error banner renders above the form
No fetch. No API route. No manual state management. The entire flow is handled by two React hooks and one async function.
Progressive enhancement — works without JavaScript
One underrated benefit of Server Actions is that they work even without JavaScript enabled in the browser. Because the form uses a native HTML action attribute pointing to a server function, the browser's default form submission behaviour kicks in as a fallback. The loading state and success UI won't appear (those need JS), but the form submission will still work and the data will still be saved.
This is a meaningful accessibility and resilience improvement over the traditional client-side fetch approach.
Why Server Actions are better than API routes for forms
- Less code — no route file, no fetch call, no JSON parsing
- Type-safe end-to-end — the action's return type flows directly to
useActionState - Server-only validation — can't be bypassed by disabling JavaScript
- Direct database access — no HTTP round-trip to your own API
- Progressive enhancement — works without JavaScript as a fallback
- Co-located logic — the action lives next to the form that uses it
Conclusion
Server Actions are one of the most practical improvements in the React and Next.js ecosystem in years. For any form that writes data — contact forms, newsletter signups, todo lists, settings pages — they eliminate an entire layer of boilerplate without sacrificing type safety or user experience.
The pattern is straightforward: one "use server" action file, one SubmitButton with useFormStatus, one form component with useActionState. That's the entire stack. Start using it on your next project and you won't go back to API routes for forms.
