Next.js API Routes and Server Actions (2026)
Next.js provides two ways to run server-side code: Route Handlers (the App Router evolution of API Routes) and Server Actions. Route Handlers are HTTP endpoints — perfect for webhooks, REST APIs and third-party integrations. Server Actions are async functions called directly from Client Components — ideal for form mutations, eliminating the need to write a separate API endpoint for every user interaction.
Table of Contents
Route Handlers
Route Handlers live in app/api/ in a file named route.ts:
// app/api/hello/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ message: 'Hello World' })
}
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')
const page = Number(searchParams.get('page') ?? '1')
const products = await db.product.findMany({
where: category ? { category } : undefined,
take: 20,
skip: (page - 1) * 20,
})
return NextResponse.json({ products, page })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const product = await db.product.create({ data: body })
return NextResponse.json(product, { status: 201 })
}
HTTP Methods
// app/api/products/[id]/route.ts
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } })
if (!product) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(product)
}
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
const body = await request.json()
const product = await db.product.update({ where: { id: params.id }, data: body })
return NextResponse.json(product)
}
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
const body = await request.json()
const product = await db.product.update({ where: { id: params.id }, data: body })
return NextResponse.json(product)
}
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
await db.product.delete({ where: { id: params.id } })
return new NextResponse(null, { status: 204 })
}
Request and Response
export async function POST(request: NextRequest) {
// Read body
const json = await request.json()
const text = await request.text()
const formData = await request.formData()
// Headers
const auth = request.headers.get('authorization')
const contentType = request.headers.get('content-type')
// Cookies
const token = request.cookies.get('token')?.value
// Custom response with headers and cookies
const response = NextResponse.json({ success: true })
response.headers.set('X-Custom-Header', 'value')
response.cookies.set('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
return response
}
// Streaming response
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
controller.enqueue('Hello ')
await sleep(100)
controller.enqueue('World')
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
}
Dynamic Route Handlers
// app/api/users/[id]/posts/route.ts — nested dynamic
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const posts = await db.post.findMany({ where: { authorId: params.id } })
return NextResponse.json(posts)
}
// Catch-all: app/api/[...slug]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string[] } }
) {
console.log(params.slug) // ['a', 'b', 'c'] for /api/a/b/c
return NextResponse.json({ path: params.slug })
}
Server Actions
// actions/posts.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title required').max(100),
body: z.string().min(10, 'Body must be at least 10 characters'),
})
export async function createPost(formData: FormData) {
const result = CreatePostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
await db.post.create({ data: result.data })
revalidatePath('/posts')
redirect('/posts')
}
// Inline Server Action
export default function NewPostPage() {
async function create(formData: FormData) {
'use server'
await db.post.create({ data: { title: formData.get('title') as string } })
revalidatePath('/posts')
}
return <form action={create}><input name="title" /><button>Create</button></form>
}
useFormState and useFormStatus
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/actions/posts'
// Initial state
const initialState = { errors: {}, message: '' }
function SubmitButton() {
const { pending } = useFormStatus() // Must be inside the form
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Create Post'}
</button>
)
}
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" />
{state.errors?.title && <p className="text-red-500">{state.errors.title[0]}</p>}
<textarea name="body" />
{state.errors?.body && <p className="text-red-500">{state.errors.body[0]}</p>}
<SubmitButton />
{state.message && <p>{state.message}</p>}
</form>
)
}
useFormState vs useActionState: React 19 / Next.js 15 introduces
useActionState as the replacement for useFormState. Same API — just rename the import from react-dom to react.
Zod Validation
// Reusable action factory with Zod
import { z } from 'zod'
function createAction<T extends z.ZodType>(schema: T, handler: (data: z.infer<T>) => Promise<unknown>) {
return async (formData: FormData) => {
const raw = Object.fromEntries(formData)
const result = schema.safeParse(raw)
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors }
}
await handler(result.data)
return { success: true }
}
}
// Usage
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2),
})
export const signup = createAction(signupSchema, async (data) => {
await createUser(data)
revalidatePath('/dashboard')
})
File Uploads
// app/api/upload/route.ts
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) return NextResponse.json({ error: 'No file' }, { status: 400 })
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Save to disk (or upload to S3)
const filename = `${Date.now()}-${file.name}`
await writeFile(path.join(process.cwd(), 'public/uploads', filename), buffer)
return NextResponse.json({ url: `/uploads/${filename}` })
}
Authentication in Route Handlers
import { cookies } from 'next/headers'
import { verifyJWT } from '@/lib/auth'
async function getAuthUser() {
const token = cookies().get('token')?.value
if (!token) return null
try {
return await verifyJWT(token)
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const user = await getAuthUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const data = await db.user.findUnique({ where: { id: user.id } })
return NextResponse.json(data)
}
// CORS for external clients
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}