Server Actions and Mutations
Mutate server data directly from client components with Server Actions
What you'll learn
Use 'use server' directive for server actions
Differentiate server actions from route handlers
Build form actions with progressive enhancement
Revalidate data after mutations with revalidatePath and revalidateTag
Handle errors gracefully in server actions
"use server" Directive
Server Actions are functions that run on the server but can be called from client code:
// app/actions.ts
"use server"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
await db.query("INSERT INTO posts (title, content) VALUES ($1, $2)", [
title,
content,
])
}
Inline Server Action
Define the action directly in the component file:
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
"use server"
const title = formData.get("title")
const content = formData.get("content")
await db.query("INSERT INTO posts (title, content) VALUES ($1, $2)", [
title,
content,
])
}
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
)
}
Server Action vs Route Handler
| Aspect | Server Action | Route Handler |
|---|---|---|
| Invocation | Direct function call | HTTP request |
| URL | None (RPC-style) | /api/... endpoint |
| Progressive enhancement | Built-in | Manual |
| Revalidation | revalidatePath / revalidateTag | Manual fetch |
| Best for | Form submissions, mutations | Webhooks, external API consumers |
Form with Revalidation
"use client"
import { revalidatePath } from "next/cache"
export default function CreatePostForm() {
async function submit(formData: FormData) {
"use server"
const res = await fetch("https://api.example.com/posts", {
method: "POST",
body: JSON.stringify({ title: formData.get("title") }),
})
if (!res.ok) {
return { error: "Failed to create post" }
}
revalidatePath("/posts") // refresh the posts page
return { success: true }
}
return (
<form action={submit}>
<input name="title" required />
<button type="submit">Create</button>
</form>
)
}
Revalidation is Critical
Mutations without revalidation leave the UI stale. Always call revalidatePath() or revalidateTag() after successful mutations, or pass a revalidating action to the client.
Error Handling
"use server"
import { revalidateTag } from "next/cache"
export async function addComment(prevState: unknown, formData: FormData) {
const postId = formData.get("postId")
const text = formData.get("text")
if (!text || typeof text !== "string" || text.trim().length === 0) {
return { error: "Comment text is required" }
}
if (text.length > 500) {
return { error: "Comment must be under 500 characters" }
}
try {
await db.query("INSERT INTO comments (post_id, text) VALUES ($1, $2)", [
postId,
text,
])
revalidateTag(`comments-${postId}`)
return { success: true }
} catch (err) {
return { error: "Database error — please try again" }
}
}
Progressive Enhancement
Server Actions with <form> work even without JavaScript:
// app/newsletter/page.tsx
export default function NewsletterPage() {
async function subscribe(formData: FormData) {
"use server"
const email = formData.get("email")
await db.query("INSERT INTO subscribers (email) VALUES ($1)", [email])
revalidatePath("/newsletter")
}
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
)
}
See It In Action
Form with Server Action + Revalidation
Code
tsx
1"use server"2// app/actions/comments.ts3 4export async function addComment(formData: FormData) {5const text = formData.get("text")6 7if (!text || typeof text !== "string" || text.trim().length === 0) {8return { error: "Comment text is required" }9}10 11if (text.length > 1000) {12return { error: "Comment must be under 1000 characters" }13}14 15console.log(`Inserting comment: ${text}`)16 17// Revalidate cache so the UI picks up the new entry18// In production: import { revalidateTag } from "next/cache"19console.log("Revalidating: comments")20 21return { success: true }22}A validated server action that extracts form data, validates text is present and under 1000 characters, logs the insert, simulates cache revalidation, and returns a success or error response.
Lesson Summary
Key Takeaways
Server Actions are functions with 'use server' that run on the server
Can be defined inline in components or in separate files
Forms with server actions work without JavaScript (progressive enhancement)
Always revalidate after mutations — use revalidatePath or revalidateTag
Return error objects for form-level error handling instead of throwing
Use route handlers for external API consumers, server actions for internal mutations