Jodei
Insights

Server Functions in Next.js 16: Mutations Without an API Layer

Next.js 16 formalises the 'Server Function' model — async functions that run on the server and are callable from the client. Here's how they work, how to handle pending state with React 19's useActionState, and where they fit in AI-native workflows.

June 8, 2026 · 5 min read

Before Next.js 13, every server-side mutation went through a Route Handler: POST /api/comments, DELETE /api/posts/:id. You wrote the endpoint, typed the request body, wired up the fetch call, handled errors in two places. Server Functions collapse that into a single async function marked with 'use server'.

The terminology in Next.js 16 is precise: a Server Function is any async function with the directive. A Server Action is a Server Function used specifically for form submission or mutation — the same concept scoped to that context. You will see both terms in the docs; they are not different things, just different levels of specificity.

Defining a Server Function

Add 'use server' inside an async function, or at the top of a dedicated file to mark every export as a Server Function:

// app/lib/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createComment(formData: FormData) {
  const body = formData.get('body') as string
  if (!body?.trim()) return { error: 'Comment cannot be empty' }

  await db.comment.create({ data: { body } })
  revalidatePath('/posts')
}

export async function deleteComment(id: string) {
  await db.comment.delete({ where: { id } })
  revalidatePath('/posts')
}

Under the hood, Next.js compiles each export into a POST endpoint and gives the client a reference to call it. No API route file, no fetch, no JSON serialisation boilerplate.

Always authenticate inside the function. Server Functions are reachable via direct POST requests from outside your app — the framework does not add any access control automatically.

Progressive enhancement with forms

Pass a Server Function to the action prop of a <form> and you get progressive enhancement for free. The form submits even before JavaScript loads:

import { createComment } from '@/lib/actions'

export function CommentForm() {
  return (
    <form action={createComment}>
      <textarea name="body" required />
      <button type="submit">Post comment</button>
    </form>
  )
}

The function receives a FormData object automatically. No e.preventDefault(), no JSON.stringify. After JavaScript hydrates, subsequent submissions do not trigger a full page reload — the transition happens in the background.

Pending state with useActionState

React 19's useActionState wraps a Server Function and gives you the last returned value plus a pending boolean. It is the canonical way to show a loading state and surface validation errors returned from the server:

'use client'

import { useActionState } from 'react'
import { createComment } from '@/lib/actions'

type State = { error?: string } | null

export function CommentForm() {
  const [state, action, pending] = useActionState<State, FormData>(
    createComment,
    null
  )

  return (
    <form action={action}>
      <textarea name="body" required />
      {state?.error && <p className="text-red-600">{state.error}</p>}
      <button type="submit" disabled={pending}>
        {pending ? 'Posting…' : 'Post comment'}
      </button>
    </form>
  )
}

The Server Function must now return State (or a compatible type) so the hook can propagate it. Return { error: '...' } for validation failures, null for success, and let revalidatePath trigger the data refresh.

Refreshing vs revalidating

Two functions control what updates after a mutation. revalidatePath and revalidateTag purge the Next.js data cache for a path or tag — cached fetch results and use cache entries are cleared. refresh() from next/cache re-renders the current route on the server and pushes the result to the client without purging any cached data:

import { revalidateTag } from 'next/cache'
import { refresh } from 'next/cache'

export async function likePost(id: string) {
  'use server'
  await db.like.create({ data: { postId: id } })

  // Use revalidateTag when you want to clear cached data for a tag
  revalidateTag(`post-${id}`)

  // Use refresh() when you just need the UI to reflect the latest server state
  // without clearing any cache entries
  // refresh()
}

The distinction matters for AI-native apps where you may have expensive cached LLM outputs. A social action like a like or bookmark should call refresh() to update the UI — no reason to bust the AI summary cache. A content edit that invalidates the summary should call revalidateTag.

Calling AI APIs from Server Functions

Server Functions are a natural fit for AI-triggered mutations: they run on the server with access to your API keys, they are streamed back to the client, and they compose with the caching layer covered in earlier posts. A common pattern is a Server Function that calls an LLM and writes the result to your database in one shot:

'use server'

import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { revalidateTag } from 'next/cache'

export async function generateSummary(articleId: string, content: string) {
  const { text } = await generateText({
    model: openai('gpt-4o-mini'),
    prompt: `Summarize in 2 sentences: ${content}`,
  })

  await db.article.update({
    where: { id: articleId },
    data: { aiSummary: text },
  })

  revalidateTag(`article-${articleId}`)
}

The AI SDK call stays on the server — your key never reaches the client. The database write and cache invalidation happen atomically in the same function. The component that triggered the action gets the updated summary on the next render without polling.

When to keep Route Handlers

Server Functions do not replace Route Handlers for everything. Use a Route Handler when you need a public HTTP endpoint (webhooks, third-party integrations), want to return a non-HTML response (binary file download, raw JSON for an external client), or need fine-grained control over status codes and response headers. For everything that starts and ends inside your Next.js app — form submissions, button clicks, AI-triggered writes — Server Functions are the simpler choice.