Next.js Server Actions — Forms, Mutations, and Progressive Enhancement
Advertisement
Introduction
Server Actions are async functions that run on the server but can be called directly from Client Components. They were stabilized in Next.js 14 and eliminate the need to create API route handlers for most form submissions and data mutations. Marked with 'use server', they execute server-side code — database writes, email sends, auth checks — triggered by a simple function call from the client.
Why This Matters
Before Server Actions, a typical form submission required: a Client Component with state, a fetch('/api/submit') call, an API route handler, and manual cache invalidation. Server Actions collapse this into a single async function. The result is less code, fewer network roundtrips, and progressive enhancement out of the box — forms work even without JavaScript.
Server Actions also close a security gap: no API endpoint means no attack surface. The function only runs when called from your own application, and the code never ships to the browser.
Creating Server Actions
Server Actions are defined with the 'use server' directive — either at the top of a dedicated file or inline inside a Server Component:
// app/actions.ts — shared actions file
'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
const post = await db.post.create({
data: { title, content, createdAt: new Date() },
})
revalidatePath('/blog')
return { success: true, postId: post.id }
}Progressive Enhancement with action Prop
Pass a Server Action directly to a form's action attribute. The form works without JavaScript loaded:
// app/blog/new/page.tsx — Server Component
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Post content" required />
<button type="submit">Publish</button>
</form>
)
}When JavaScript loads, Next.js intercepts the submission and calls the action without a full page reload.
Validation with Zod
Always validate on the server — client-side validation can be bypassed:
// app/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const PostSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters'),
content: z.string().min(20, 'Content must be at least 20 characters'),
slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
})
export async function createPost(formData: FormData) {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
slug: formData.get('slug'),
}
const result = PostSchema.safeParse(raw)
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
await db.post.create({ data: result.data })
revalidatePath('/blog')
return { success: true }
}Handling Action State with useFormState
Display validation errors returned from Server Actions:
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Publishing...' : 'Publish Post'}
</button>
)
}
const initialState = { errors: {}, success: false }
export function CreatePostForm() {
const [state, formAction] = useFormState(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>
<SubmitButton />
{state.success && <p className="text-green-500">Post published!</p>}
</form>
)
}Authentication in Server Actions
Check authentication at the start of every action that modifies data:
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
return { error: 'You must be logged in' }
}
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) return { error: 'Post not found' }
if (post.authorId !== session.user.id && session.user.role !== 'admin') {
return { error: 'Not authorized to delete this post' }
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/blog')
return { success: true }
}Optimistic Updates with useOptimistic
Update the UI immediately while the server action is pending:
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
type Post = { id: string; title: string; likes: number; likedByUser: boolean }
export function PostCard({ post }: { post: Post }) {
const [isPending, startTransition] = useTransition()
const [optimisticPost, setOptimisticPost] = useOptimistic(
post,
(state, liked: boolean) => ({
...state,
likedByUser: liked,
likes: liked ? state.likes + 1 : state.likes - 1,
})
)
function handleLike() {
startTransition(async () => {
setOptimisticPost(!optimisticPost.likedByUser)
await toggleLike(post.id)
})
}
return (
<div>
<h2>{post.title}</h2>
<button onClick={handleLike} disabled={isPending}>
{optimisticPost.likedByUser ? '❤️' : '🤍'} {optimisticPost.likes}
</button>
</div>
)
}Redirecting After Mutations
Use redirect() from next/navigation inside Server Actions:
// app/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createAndRedirect(formData: FormData) {
const title = formData.get('title') as string
const post = await db.post.create({
data: { title, content: '' },
})
revalidatePath('/blog')
redirect(`/blog/${post.id}/edit`) // Throws internally — do not wrap in try/catch
}Note: redirect() works by throwing a special error. Do not call it inside a try/catch block unless you re-throw it.
Common Mistakes
- Not revalidating after mutations — the UI shows stale data because Next.js serves cached content
- Wrapping
redirect()in a try/catch, which catches the redirect and suppresses it - Forgetting to authenticate — Server Actions are callable from any client that knows the endpoint
- Putting large database queries in actions that are called on every keystroke
- Not using
useFormStatusto disable the submit button during pending state, causing double-submits
Best Practices
- Define shared Server Actions in a dedicated
app/actions.tsfile and import where needed - Validate all input with Zod before any database or side-effect operations
- Always check authentication at the top of mutation actions
- Call
revalidatePathorrevalidateTagafter every mutation to keep the UI fresh - Use
useFormState+useFormStatusfor rich form feedback without extra state management - Use
useOptimisticfor instant UI feedback on toggle and like interactions
Key Takeaways
- Server Actions are marked with
'use server'and run only on the server — the code never reaches the browser - Passing a Server Action to
form action=enables progressive enhancement — forms work without JavaScript useFormStatecaptures the return value of a Server Action and updates the UI with errors or success stateuseFormStatusprovides apendingboolean to disable buttons during form submission- Always validate with Zod on the server — client-side validation can be bypassed
revalidatePathandrevalidateTagmust be called after mutations to invalidate Next.js cachesredirect()inside Server Actions throws internally — never wrap it in try/catch- Authentication must be checked inside every action — there is no automatic protection
Advertisement