I used to write an API route for every form submission.
Register form → /api/auth/register
Contact form → /api/contact
Update profile → /api/users/update
Server Actions changed everything.
Here's how I use them in every Next.js 15 project now.
What Are Server Actions
Server Actions are async functions that run on the server — called directly from your components. No API route needed.
// Before Server Actions
async function handleSubmit(e) {
e.preventDefault();
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ name, email, message })
});
const data = await res.json();
}
// With Server Actions
async function submitContact(formData: FormData) {
'use server';
const name = formData.get('name');
const email = formData.get('email');
// directly save to database
await db.contacts.create({ name, email });
}
The 'use server' directive tells Next.js this function runs on the server even when called from a client component.
1. Basic Form with Server Action
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
if (!name || !email || !message) {
throw new Error('All fields required');
}
// Save to database directly
await db.messages.create({ data: { name, email, message } });
}
return (
<form action={submitForm}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send Message</button>
</form>
);
}
No useState. No fetch. No API route. Just a function.
2. With Loading and Error States
'use client';
import { useActionState } from 'react';
async function submitForm(prevState: any, formData: FormData) {
'use server';
try {
const email = formData.get('email') as string;
await db.subscribers.create({ data: { email } });
return { success: true, message: 'Subscribed successfully!' };
} catch (error) {
return { success: false, message: 'Something went wrong.' };
}
}
export function SubscribeForm() {
const [state, action, isPending] = useActionState(submitForm, null);
return (
<form action={action}>
<input name="email" type="email" placeholder="Enter email" />
<button type="submit" disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
{state?.message && (
<p className={state.success ? 'text-green-400' : 'text-red-400'}>
{state.message}
</p>
)}
</form>
);
}
useActionState gives you pending state and the previous result automatically.
3. Revalidating Data After Mutation
After updating data you need to refresh the UI. Use revalidatePath:
// actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.create({ data: { title, content } });
// Refresh the posts page so new post appears
revalidatePath('/dashboard/posts');
}
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
revalidatePath('/dashboard/posts');
}
Usage in component:
import { deletePost } from '@/actions/posts';
export function PostCard({ post }) {
return (
<div>
<h3>{post.title}</h3>
{/* Pass server action directly to form action */}
<form action={deletePost.bind(null, post.id)}>
<button type="submit">Delete</button>
</form>
</div>
);
}
4. Redirect After Action
'use server';
import { redirect } from 'next/navigation';
export async function login(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await verifyCredentials(email, password);
if (!user) {
return { error: 'Invalid credentials' };
}
// Set cookie
cookies().set('auth-token', generateToken(user.id), {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24 * 7,
});
// Redirect to dashboard
redirect('/dashboard');
}
5. Separate Actions File (Best Practice)
Don't put all your actions inside components. Create a dedicated file:
// actions/users.ts
'use server';
import { connectDB } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import User from '@/models/User';
export async function updateProfile(formData: FormData) {
await connectDB();
const name = formData.get('name') as string;
const userId = formData.get('userId') as string;
await User.findByIdAndUpdate(userId, { name });
revalidatePath('/dashboard/settings');
}
export async function deleteAccount(userId: string) {
await connectDB();
await User.findByIdAndDelete(userId);
redirect('/');
}
Import and use anywhere:
import { updateProfile } from '@/actions/users';
export function ProfileForm({ user }) {
return (
<form action={updateProfile}>
<input type="hidden" name="userId" value={user.id} />
<input name="name" defaultValue={user.name} />
<button type="submit">Save Changes</button>
</form>
);
}
When to Still Use API Routes
Server Actions are not always the answer:
| Use Server Actions | Use API Routes |
|---|---|
| Form submissions | Third party webhooks |
| CRUD operations | Mobile app backend |
| Page mutations | Public API |
| Auth flows | File uploads |
If an external service needs to call your endpoint — use an API route. If it's your own Next.js app talking to itself — use Server Actions.
Summary
Old way: Component → fetch → API Route → Database
New way: Component → Server Action → Database
Less code. Faster development. Same result.
I use Server Actions in all my Next.js templates for contact forms, auth flows, and dashboard mutations.
See them in action:
Get the templates: https://pixelanas.gumroad.com
What do you use — Server Actions or API Routes? Drop it below 👇
Anas — full-stack Next.js developer. X: @pixelanas
Top comments (0)