Skip to content

Server Actions and AI: What Fits and What Doesn't

10 min read · updated August 4, 2026

A Server Action is an RPC: the client calls a function, the server runs it, one serialisable value comes back. Model calls that produce one answer fit that shape perfectly. Model calls a user watches token by token do not, and the reason is structural rather than a missing feature.

What a Server Action actually is

A function marked "use server" is compiled into an endpoint. Next.js gives it a generated id, and importing it into a client component gives you a stub that POSTs the arguments to that endpoint and awaits the result. The function body never reaches the browser bundle, which is why secrets are safe inside it.

// app/actions/summarise.ts
"use server";

import { z } from "zod";

const Input = z.object({ text: z.string().min(1).max(20_000) });

export async function summarise(raw: unknown) {
  const parsed = Input.safeParse(raw);
  if (!parsed.success) return { ok: false as const, error: "invalid input" };

  const res = await fetch("https://api.multigrid.ai/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + process.env.LLM_API_KEY,
    },
    body: JSON.stringify({
      model: "openai/gpt-4o-mini",
      messages: [
        { role: "system", content: "Summarise in three bullet points." },
        { role: "user", content: parsed.data.text },
      ],
      max_tokens: 300,
    }),
    signal: AbortSignal.timeout(25_000),
  });

  if (!res.ok) return { ok: false as const, error: "model unavailable" };

  const data = await res.json();
  return {
    ok: true as const,
    summary: data.choices[0].message.content as string,
    usage: data.usage as { total_tokens: number } | undefined,
  };
}

Returning a discriminated result object rather than throwing is deliberate. An exception thrown inside an action is deliberately scrubbed before it reaches the client in production — the browser gets a generic message and a digest, not your stack trace — which is correct for security and useless for showing the user why their request failed. A returned { ok: false, error } crosses the boundary intact.

What fits: the non-streaming call

Server Actions are a genuinely better fit than a route handler for a whole class of AI work, and the advantage is not syntax. It is that the action can revalidate.

  • Classification and extraction. The user submits a form, the model returns structured data, the action writes it and calls revalidatePath. The page re-renders with the new data on the server. A route handler cannot do that half.
  • Anything progressively enhanceable. An action passed to a form’s action prop submits as a real form post before hydration. For a “summarise this” button that is free resilience.
  • Background-ish work. Kick off a job, write a row, return an id. The client polls or subscribes for the result.
  • Anything where the answer is short. Under about two seconds, streaming buys nothing a spinner does not, and an optimistic update covers the gap better than a token animation.

The line: one value, one time

Here is the property that decides everything. The client stub awaits one resolution of one serialised payload. There is no point in the calling convention at which the client can observe progress, because there is nothing to observe until the function returns. A ReadableStream is not serialisable across that boundary, and neither is an async generator.

So the moment your requirement is “the user watches the words appear”, you need a transport that delivers many payloads over one connection. That is an HTTP response body — a route handler. This is not a gap that a future release closes; it is what “call a function, get its return value” means.

There is a real and important qualification here. React Server Components can send a payload progressively, and libraries built on that — including streamable-value helpers in AI SDKs — use it to make an action appear to stream by returning a promise the RSC payload resolves in pieces. That machinery works, and it is also the fastest-moving surface in the whole React ecosystem: the helper names, their module paths and their semantics have all changed across versions. If you use one, pin the version and read that version’s own documentation. The mechanism explained above is what stays true underneath whichever wrapper is current.

The hybrid that most apps end up with

Mature codebases stop choosing. They use both, split by whether the user is watching.

  1. Action for the mutation. The user submits. The action validates, checks who is paying for the call, writes a messages row with status pending, and returns the row id. Cheap, fast, transactional.
  2. Route handler for the stream. The client opens /api/chat/stream?id=... and reads tokens, exactly as in the streaming route handler.
  3. The handler persists the finished text and flips the row to complete, so a reload shows the answer without regenerating it. See storing chat history.
  4. An action for everything after. Rename the conversation, delete a branch, retry a message: mutations that end in a revalidation, which is what actions are best at.

The one thing to avoid is streaming through an action-shaped wrapper you do not understand, because when it breaks it breaks at the RSC serialisation layer and the error message will be about a payload, not about your model call.

An action is a public endpoint

This is the part that costs people money. A Server Action looks like a function call, so it inherits a function call’s intuitions about trust — and it has none of them. It compiles to a POST endpoint with a stable id that anybody can call directly, with any arguments, any number of times, without ever loading your page.

Every guard the UI appeared to provide is decorative. A disabled button, a hidden field, a client-side length check, a component that only renders for signed-in users: none of it exists at the endpoint.

"use server";

import { z } from "zod";
import { auth } from "@/lib/auth";
import { checkRateLimit } from "@/lib/ratelimit";

const Input = z.object({ text: z.string().min(1).max(20_000) });

export async function summarise(raw: unknown) {
  // 1. Authenticate. Never trust a user id passed as an argument.
  const session = await auth();
  if (!session) return { ok: false as const, error: "sign in" };

  // 2. Authorise. Being signed in is not permission to spend.
  if (!session.canSpend) return { ok: false as const, error: "no permission" };

  // 3. Validate. The arguments are attacker-controlled.
  const parsed = Input.safeParse(raw);
  if (!parsed.success) return { ok: false as const, error: "invalid input" };

  // 4. Rate limit, keyed on the account and not on anything from the client.
  const limit = await checkRateLimit("summarise:" + session.accountId, 20, 60_000);
  if (!limit.allowed) {
    return { ok: false as const, error: "slow down for " + limit.retryAfter + "s" };
  }

  // ... the model call
}

Those four checks belong at the top of every action that spends money, in that order, with no early exit that skips one. Authenticate before authorise, because the second needs the first; validate before the model call, because a 20,000-character cap you did not enforce is a bill you did not expect; rate limit last of the four, keyed on the authenticated account, because keying it on anything the client sent is the same as not having one. The full version of the last two is in rate limiting an AI endpoint.