Next.js Server Actions — Replace API Routes with Type-Safe Server Functions 2026
Advertisement
Introduction
Why This Matters
Every traditional Next.js app has the same boilerplate: define an API route in app/api/, write a fetch call in the component, handle loading and error state, and parse the JSON response. Server Actions collapse all of that into a single async function. You define the function with 'use server', call it from your component, and Next.js handles the HTTP transport invisibly.
In 2026, Server Actions are the recommended pattern for data mutations in Next.js. They work with progressive enhancement (forms work without JavaScript), they integrate directly with React 19's form APIs, and they support cache invalidation via revalidatePath and revalidateTag.
What Server Actions Are
A Server Action is an async function marked with the 'use server' directive. When called from a Client Component, Next.js automatically sends the arguments to the server, executes the function there, and returns the result to the client — no manual fetch, no API route, no JSON serialization.
Client Component
↓ calls createPost('title', 'content')
Next.js runtime
↓ serializes arguments → POST /next/action
Server (Node.js / Edge)
↓ executes createPost on the server
↓ runs DB query, validates auth, revalidates cache
Return value
↓ serialized back to clientYour First Server Action
Define actions in a dedicated file with 'use server' at the top:
// app/actions/posts.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(title: string, content: string) {
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' }
}
const post = await db.post.create({
data: { title, content },
})
revalidatePath('/blog') // Invalidate the blog listing cache
redirect(`/blog/${post.id}`) // Navigate to the new post
}Call it from a Client Component like a normal async function:
'use client'
import { createPost } from '@/app/actions/posts'
import { useState } from 'react'
export function CreatePostForm() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
const data = new FormData(e.currentTarget)
const result = await createPost(
data.get('title') as string,
data.get('content') as string,
)
setLoading(false)
if (result?.error) setError(result.error)
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Content" required />
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}Binding Actions Directly to Forms
The cleanest pattern — bind a Server Action to a form's action attribute. The form works even without JavaScript (progressive enhancement):
// app/contact/page.tsx — Server Component
import { submitContact } from '@/app/actions/contact'
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" required placeholder="Your name" />
<input name="email" type="email" required placeholder="Your email" />
<textarea name="message" required placeholder="Message" />
<button type="submit">Send Message</button>
</form>
)
}// app/actions/contact.ts
'use server'
import { redirect } from 'next/navigation'
export async function submitContact(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
await sendEmail({ name, email, message })
redirect('/contact/thank-you')
}useActionState for Loading and Error UX
React 19's useActionState (previously useFormState) manages form state with a Server Action:
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions/posts'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Create Post'}
</button>
)
}
export function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, null)
return (
<form action={formAction}>
{state?.error && <div className="error-banner">{state.error}</div>}
{state?.success && <div className="success-banner">Post created!</div>}
<input name="title" required placeholder="Title" />
<textarea name="content" required placeholder="Content" />
<SubmitButton />
</form>
)
}The Server Action receives prevState as its first argument when used with useActionState:
'use server'
export async function createPost(
prevState: { error?: string; success?: boolean } | null,
formData: FormData,
) {
const title = formData.get('title') as string
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' }
}
try {
await db.post.create({ data: { title } })
revalidatePath('/blog')
return { success: true }
} catch {
return { error: 'Database error. Please try again.' }
}
}Validation with Zod
Always validate Server Action inputs server-side — clients can bypass browser validation:
'use server'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
role: z.enum(['user', 'admin']),
})
export async function createUser(formData: FormData) {
const result = CreateUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
})
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
await db.user.create({ data: result.data })
revalidatePath('/users')
return { success: true }
}Authentication Guards
Always verify auth inside Server Actions — the 'use server' directive does not protect them from being called:
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) {
return { error: 'Post not found' }
}
if (post.authorId !== session.user.id) {
return { error: 'You can only delete your own posts' }
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/blog')
return { success: true }
}Optimistic Updates with useOptimistic
Provide instant feedback with useOptimistic — update the UI immediately and roll back if the server call fails:
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/posts'
export function LikeButton({ postId, initialLikes, userHasLiked }: Props) {
const [optimisticLikes, setOptimisticLikes] = useOptimistic(
{ count: initialLikes, liked: userHasLiked },
(state) => ({ count: state.liked ? state.count - 1 : state.count + 1, liked: !state.liked })
)
async function handleLike() {
setOptimisticLikes(undefined) // Apply optimistic update instantly
await toggleLike(postId) // Then sync with server
}
return (
<button onClick={handleLike}>
{optimisticLikes.liked ? 'Unlike' : 'Like'} ({optimisticLikes.count})
</button>
)
}Common Mistakes
Not adding 'use server' to action files. Without the directive, the function runs on the client. The build will not catch this — only the runtime will.
Missing auth checks inside actions. Server Actions are HTTP endpoints. Any user who can call the form can call the action directly. Always verify the session.
Returning thrown errors to the client. Catch exceptions inside actions and return structured error objects — never let raw database or system errors propagate to the browser.
Calling Server Actions inside 'use server' files. Actions can call other server-only utilities, but chaining actions does not compose well. Extract shared logic into plain server-side functions.
Best Practices
- Keep action files in
app/actions/grouped by domain (posts, users, comments) — do not scatter'use server'functions across component files. - Validate every input with Zod before touching the database, even if the form has client-side validation.
- Use
revalidatePathorrevalidateTaginstead of client-side cache invalidation — the server controls what is stale. - Return plain serializable objects (
{ success: true }or{ error: 'message' }) — avoid returning class instances or Promises. - Use
useFormStatusfromreact-domto derive the pending state of the nearest form's action rather than managingloadingstate manually.
Key Takeaways
- Server Actions are
asyncfunctions marked with'use server'— they run on the server but can be called directly from Client Components. - When a Server Action is bound to a
form action, the form works with progressive enhancement even without JavaScript in the browser. useActionState(React 19) manages form state, loading, and error feedback from a Server Action in a single hook.- Every Server Action must independently verify authentication — the
'use server'directive provides no access control on its own. - Validate all inputs with Zod inside the action itself — client-side validation can always be bypassed.
revalidatePathandrevalidateTaginvalidate specific Next.js cache entries from inside a Server Action, keeping the UI consistent after mutations.useOptimisticenables instant UI feedback while the Server Action runs, with automatic rollback if the action fails.- Return structured objects (
{ success, error, data }) from actions rather than throwing — it integrates cleanly withuseActionStatestate management.
Advertisement