Next Server Actions¶
The 'use server' directive turns a function into an API endpoint without writing a route handler. Forms submit directly to the server. Mutations happen without building a REST API. It's elegant until someone sends you a malformed FormData that crashes your server
Server Actions are Next.js's answer to the age-old question "do I really need an API route for this?" For form submissions , button clicks , and any mutation that originates from user interaction , Server Actions are the right tool. For everything else , use route handlers
basic Server Action¶
Define a 'use server' function. Call it from a form
// src/app/contact/page.tsx
export default function ContactPage() {
async function submitContact(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
// Server-side validation. This is where real validation lives
if (!name || !email || !message) {
throw new Error('All fields are required')
}
if (!email.includes('@')) {
throw new Error('Invalid email')
}
// Save to database
await prisma.contact.create({
data: { name, email, message }
})
// Revalidate the page so the user sees updated state
revalidatePath('/contact')
}
return (
<form action={submitContact}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" type="email" />
<textarea name="message" placeholder="Message" />
<button type="submit">Send</button>
</form>
)
}
The 'use server' directive is required at the top of the function body. Without it , the function runs on the client and anyone can read it in the browser bundle
separate file pattern¶
For reusable Server Actions , put them in a separate file
// src/app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = parseFloat(formData.get('price') as string)
if (!name || isNaN(price)) {
return { error: 'Invalid input' }
}
await prisma.product.create({ data: { name, price } })
revalidatePath('/products')
return { success: true }
}
export async function deleteProduct(id: string) {
await prisma.product.delete({ where: { id } })
revalidatePath('/products')
}
// src/app/products/page.tsx
import { createProduct, deleteProduct } from './actions'
export default function ProductsPage() {
return (
<form action={createProduct}>
<input name="name" placeholder="Name" />
<input name="price" placeholder="Price" type="number" step="0.01" />
<button type="submit">Create</button>
</form>
)
}
Server Actions in separate files are importable from any page or component. Mark the entire file with 'use server' at the top
Client Component calling Server Actions¶
You can pass Server Actions to Client Components without making them 'use server' themselves
// src/app/todos/page.tsx
import { toggleTodo } from './actions'
import TodoList from './TodoList'
export default function TodosPage() {
return <TodoList toggleTodo={toggleTodo} />
}
// src/app/todos/TodoList.tsx
'use client'
export default function TodoList({
toggleTodo
}: {
toggleTodo: (id: string) => Promise<void>
}) {
async function handleToggle(id: string) {
// This calls the server action
await toggleTodo(id)
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={() => handleToggle(todo.id)}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</li>
))}
</ul>
)
}
The Server Action is passed as a prop. The Client Component calls it like a regular async function. The server code never ships to the browser
mutation patterns¶
After a mutation , you need to refresh the data. Three options:
'use server'
import { revalidatePath, revalidateTag, redirect } from 'next/cache'
export async function updateProduct(id: string, formData: FormData) {
// 1. update database
await prisma.product.update({
where: { id },
data: { name: formData.get('name') as string }
})
// 2. revalidate specific path
revalidatePath('/products')
revalidatePath(`/products/${id}`)
// 3. OR revalidate by tag
revalidateTag('products')
// 4. redirect to another page
redirect('/products')
}
redirect() throws an error internally (it uses next/navigation under the hood). Call it after revalidation , not before
loading and error states¶
Server Actions support useActionState for tracking pending state
'use client'
import { useActionState } from 'react'
import { createProduct } from './actions'
const initialState = { error: '', success: false }
export default function ProductForm() {
const [state, formAction, pending] = useActionState(createProduct, initialState)
return (
<form action={formAction}>
<input name="name" placeholder="Name" disabled={pending} />
<input name="price" placeholder="Price" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Product'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Product created!</p>}
</form>
)
}
useActionState gives you pending state and the return value from the Server Action. No useState , no useEffect , no manual loading states
security notes¶
Server Actions have security guardrails but they're not a silver bullet
Built-in CSRF protection: Next.js generates a cryptographically signed token for every Server Action. Forms that submit without the correct token get rejected. This prevents cross-site request forgery attacks
What's NOT protected: * No rate limiting - nothing stops a bot from submitting your form 10,000 times. Implement rate limiting yourself * No input validation - Server Actions don't validate input by default. You write the validation * No auth enforcement - any page that imports a Server Action can call it. Protect actions behind auth checks * Error messages - throw an error with a sensitive message and it might leak stack traces
Always validate and auth-check inside the Server Action:
'use server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function deleteAccount(userId: string) {
const session = await getServerSession(authOptions)
// Auth check - inside the action , not in the UI
if (!session || session.user.id !== userId) {
throw new Error('Unauthorized')
}
await prisma.user.delete({ where: { id: userId } })
revalidatePath('/admin/users')
}
prerequisites¶
next_06_data_fetching.md - you should understand data fetching patterns and revalidation
next → next_08_api_routes.md