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.

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',
    },
  })
}