Next.js 14 introduces Server Actions as a stable feature, revolutionizing how we handle server-side mutations and form submissions in React applications. This comprehensive guide covers everything you need to know.
Next.js Server Actions Flow Diagram
What Are Server Actions?
Server Actions are asynchronous functions that run on the server and can be called from both Server and Client Components. They enable you to handle form submissions and data mutations without writing API routes.
Key Benefits
- Simplified Data Mutations: No need for separate API endpoints
- Progressive Enhancement: Forms work without JavaScript
- Type Safety: End-to-end TypeScript support
- Automatic Revalidation: Built-in cache revalidation
- Better DX: Less boilerplate code
Basic Usage
Creating a Server Action
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Validate data
if (!title || !content) {
throw new Error('Title and content are required')
}
// Database operation
await db.post.create({
data: { title, content }
})
// Revalidate cache
revalidatePath('/posts')
// Redirect
redirect('/posts')
}
Using in Server Components
import { createPost } from './actions'
export default function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
)
}
Using in Client Components
'use client'
import { createPost } from './actions'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export default function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="content" required />
<SubmitButton />
</form>
)
}
Advanced Patterns
Form Validation with Zod
'use server'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
tags: z.array(z.string()).optional(),
})
export async function createPost(formData: FormData) {
const data = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
}
// Validate
const validatedData = createPostSchema.parse(data)
// Process...
await db.post.create({ data: validatedData })
revalidatePath('/posts')
}
Error Handling
'use server'
import { z } from 'zod'
type State = {
errors?: {
title?: string[]
content?: string[]
}
message?: string
}
export async function createPost(
prevState: State,
formData: FormData
): Promise<State> {
try {
const validatedData = createPostSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
})
await db.post.create({ data: validatedData })
revalidatePath('/posts')
return { message: 'Post created successfully' }
} catch (error) {
if (error instanceof z.ZodError) {
return {
errors: error.flatten().fieldErrors,
message: 'Validation failed',
}
}
return { message: 'Failed to create post' }
}
}
Using with useFormState
'use client'
import { useFormState } from 'react-dom'
import { createPost } from './actions'
const initialState = { message: '', errors: {} }
export default function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState)
return (
<form action={formAction}>
<div>
<input name="title" type="text" />
{state.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<textarea name="content" />
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
{state.message && <p>{state.message}</p>}
<button type="submit">Submit</button>
</form>
)
}
Optimistic Updates
'use client'
import { useOptimistic } from 'react'
import { addTodo } from './actions'
export default function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: string) => [
...state,
{ id: Date.now(), text: newTodo, completed: false }
]
)
async function formAction(formData: FormData) {
const text = formData.get('todo') as string
addOptimisticTodo(text)
await addTodo(text)
}
return (
<>
<form action={formAction}>
<input name="todo" type="text" />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
)
}
Security Considerations
1. Always Validate Input
'use server'
export async function updateProfile(formData: FormData) {
// Never trust client input
const validated = profileSchema.parse({
name: formData.get('name'),
email: formData.get('email'),
})
// Proceed with validated data
}
2. Implement Authorization
'use server'
import { auth } from '@/lib/auth'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const post = await db.post.findUnique({ where: { id: postId } })
if (post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
}
3. Rate Limiting
'use server'
import { ratelimit } from '@/lib/redis'
export async function sendEmail(formData: FormData) {
const ip = headers().get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
throw new Error('Too many requests')
}
// Proceed with email sending
}
Performance Optimization
1. Partial Prerendering
// app/posts/page.tsx
export default function PostsPage() {
return (
<>
{/* Static content */}
<Header />
{/* Dynamic content with Server Action */}
<Suspense fallback={<Skeleton />}>
<PostsList />
</Suspense>
</>
)
}
2. Selective Revalidation
'use server'
export async function updatePost(id: string, data: PostData) {
await db.post.update({ where: { id }, data })
// Only revalidate affected paths
revalidatePath(`/posts/${id}`)
revalidatePath('/posts', 'page') // Just the page, not layouts
}
3. Streaming Updates
'use server'
export async function processLargeFile(formData: FormData) {
const file = formData.get('file') as File
// Stream processing
const stream = file.stream()
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
// Process chunk
await processChunk(value)
}
}
Testing Server Actions
import { createPost } from './actions'
import { describe, it, expect, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
describe('createPost', () => {
it('creates a post successfully', async () => {
const formData = new FormData()
formData.append('title', 'Test Post')
formData.append('content', 'Test Content')
await createPost(formData)
const post = await db.post.findFirst({
where: { title: 'Test Post' }
})
expect(post).toBeDefined()
expect(post.content).toBe('Test Content')
})
it('validates input', async () => {
const formData = new FormData()
formData.append('title', 'ab') // Too short
await expect(createPost(formData)).rejects.toThrow()
})
})
Conclusion
Server Actions in Next.js 14 provide a powerful, type-safe way to handle server-side mutations. They simplify full-stack development by eliminating the need for separate API routes while maintaining progressive enhancement and security.
Key takeaways:
- Use
'use server'directive for server-only code - Leverage built-in hooks for better UX
- Always validate and authorize
- Implement proper error handling
- Use selective revalidation for performance
Server Actions represent the future of full-stack React development, and mastering them will significantly improve your Next.js applications.

