Server Actions let you write server-side functions that your components call directly -- no API route boilerplate, no fetch calls, no separate endpoint file.
Basic Server Action
// app/actions.ts
'use server'
import { db } from '@/lib/db'
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
if (!title || !content) {
return { error: 'Title and content are required' }
}
const post = await db.posts.create({ title, content })
revalidatePath('/blog') // Invalidate blog page cache
return { success: true, post }
}
// app/new-post/page.tsx (Server Component)
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
This works with JavaScript disabled (progressive enhancement) and doesn't need an API route.
Validation with Zod
'use server'
import { z } from 'zod'
const PostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content too short').max(10000),
published: z.coerce.boolean()
})
export async function createPost(formData: FormData) {
const parsed = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
})
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors
}
}
const post = await db.posts.create(parsed.data)
revalidatePath('/blog')
return { post }
}
useActionState for Error Handling
'use client'
import { useActionState } from 'react'
import { createPost } from '../actions'
const initialState = { errors: {}, post: null }
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<div>
<input name="title" placeholder="Title" />
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<textarea name="content" placeholder="Content" />
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Optimistic Updates
'use client'
import { useOptimistic } from 'react'
import { likePost } from '../actions'
export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
const [optimisticCount, addOptimisticLike] = useOptimistic(
initialCount,
(current, _) => current + 1
)
return (
<form action={async () => {
addOptimisticLike(null) // Immediately update UI
await likePost(postId) // Then do the real action
}}>
<button type="submit">
{optimisticCount} likes
</button>
</form>
)
}
The UI updates instantly -- if the server action fails, React rolls back the optimistic update.
Authentication in Server Actions
'use server'
import { cookies } from 'next/headers'
import { verifyToken } from '@/lib/auth'
export async function deletePost(postId: string) {
// Always verify auth in server actions
const token = cookies().get('session')?.value
const user = await verifyToken(token)
if (!user) {
return { error: 'Unauthorized' }
}
const post = await db.posts.findById(postId)
if (post.authorId !== user.id) {
return { error: 'Forbidden' }
}
await db.posts.delete(postId)
revalidatePath('/blog')
return { success: true }
}
Server Actions vs API Routes
| Server Actions | API Routes | |
|---|---|---|
| Use case | Form mutations, simple data ops | Public API, webhooks, external consumers |
| Type safety | End-to-end TypeScript | Manual (or tRPC) |
| Client code | No fetch/axios needed | fetch() or axios |
| Caching/revalidation | Built-in with revalidatePath | Manual |
| External access | No (server-only) | Yes (public URL) |
| File uploads | FormData native | Need middleware |
Use Server Actions for mutations in your own Next.js app. Use API Routes for endpoints consumed by external services, mobile apps, or if you need a public API.
Server Actions eliminate an entire layer of boilerplate for typical web app mutations. The form -> validation -> database -> cache invalidation -> redirect flow that used to need an API route, manual fetch, error handling on both sides now fits in a single file.