Skip to content

Server actions

Calling the server from a client component, as an ordinary function.

Updated View as Markdown

A server action is an async function a client component can call directly. Mark the module "use server" and its exports become callable from the browser; the body stays on the server.

Writing one

src/actions.tsts
'use server'

import { revalidate } from '@rsc-kit/core/revalidate'
import { addOrder } from './orders'

let total = 0

// Called straight from a client component as an ordinary async function. The
// body never reaches the browser; the call becomes a POST to /_rsc/action.
export async function addToTotal(amount: number): Promise<number> {
  total += amount

  return total
}

// Marks the orders list stale. The re-rendered list travels back with this
// action's own answer, so the client never makes a second request for it.
export async function placeOrder(item: string): Promise<{ ok: true }> {
  await addOrder(item)
  revalidate('orders')

  return { ok: true }
}

Nothing is registered and nothing is generated. The build turns each export into a reference, and the import in a client component resolves to a stub that performs the call.

Calling one

src/components/AddOrder.tsxtsx
'use client'

import { useState } from 'react'
import { placeOrder } from '../actions'

export function AddOrder() {
  const [item, setItem] = useState('')
  const [note, setNote] = useState('')

  return (
    <div className="add-order">
      <input value={item} onChange={(e) => setItem(e.target.value)} placeholder="Item" id="item" />
      <button
        id="place"
        onClick={async () => {
          await placeOrder(item)
          setItem('')
        }}
      >
        Place order
      </button>
      {/* Deliberately untouched by the action: proof the page was not replaced. */}
      <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="A note" id="note" />
    </div>
  )
}

import { placeOrder } does not pull actions.ts into the browser bundle. Anything that module imports — a database client, a secret — stays on the server with it.

What happens on the wire

  1. The client component calls the function.
  2. React serialises the arguments in the Flight format.
  3. The browser sends one POST /_rsc/action carrying the action’s id and the encoded arguments.
  4. The host decodes them and runs the real function.
  5. The return value is serialised as Flight and streamed back.

Files travel this way too, without any encoding of your own — see File uploads.

The id is a hash of the module and the export in a build — 9396f92f746f#createOrder — and nothing about the file is in it. In development it is the module’s path as Vite serves it (/@fs/Users/…/actions.ts#createOrder), which is what a $ACTION_ID_… hidden input in a form shows there; a build never carries it.

Returning UI

Step 5 is the same serialiser a page goes through, so an action can answer with elements instead of data — server components, client components, Suspense boundaries — and the caller receives real React elements to render:

src/actions.tsxtsx
'use server';

import { Suspense } from 'react';
import { Counter } from './Counter';

export async function renderCard(name: string) {
  return (
    <section>
      <h2>{name}</h2>
      <Counter />
      <Suspense fallback={<p>loading…</p>}>
        <Related to={name} />
      </Suspense>
    </section>
  );
}
src/CardButton.tsxtsx
'use client';

const [ui, setUi] = useState<ReactNode>(null);

<button onClick={async () => setUi(await renderCard('ada'))}>card</button>
{ui}

The answer streams. The promise resolves as soon as the root row arrives, so the card mounts with its fallback showing, and <Related> fills the hole when it is done — the action did not wait for it. The browser never receives <Related>’s code: it ran on the server, and only its output crossed.

Streaming tokens

That is the primitive behind “generative UI”. An async component that renders one chunk and suspends on the next is a streamed answer, with nothing to install:

src/actions.tsxtsx
'use server';

export async function ask(prompt: string) {
  const tokens = model.stream(prompt);   // an AsyncIterator<string>

  return (
    <Suspense fallback={<p>thinking…</p>}>
      <Tokens from={tokens[Symbol.asyncIterator]()} />
    </Suspense>
  );
}

async function Tokens({ from }: { from: AsyncIterator<string> }) {
  const { value, done } = await from.next();

  if (done) return null;

  return (
    <>
      {value}
      <Suspense fallback={null}>
        <Tokens from={from} />
      </Suspense>
    </>
  );
}

Each token is a row in the stream and each <Suspense> a hole the next one fills. That is what createStreamableUI from Vercel’s AI SDK does under the hood, in Next as here — the same React serialiser.

Reach for it when the server knows what to render and the client should not: a card, a chart, a widget. A chat wants data streamed to the client instead (streamText and useChat in the AI SDK), because a half-streamed element tree cannot be cancelled, resumed, retried or persisted, and this one is a tree as deep as the token count.

Refreshing what the action changed

An action that changes data usually makes something on screen wrong. Mark it stale with revalidate, and the re-rendered content travels back with the action’s own answer:

placeOrder in the module above does exactly that: it writes the order, then calls revalidate('orders').

The tag names a section — a region of the page registered under a name the server can address on its own:

src/app/orders/orders.section.tsxtsx
import { section } from '@rsc-kit/core/section'
import { listOrders } from '../../orders'

// A named region. section() registers it under a name the client can refresh
// and an action can mark, so this list re-renders without the page around it
// being touched.
async function Orders() {
  const orders = await listOrders()

  return (
    <ul className="orders">
      {orders.map((order) => (
        <li key={order.id}>{order.item}</li>
      ))}
    </ul>
  )
}

export default section('orders', Orders)

One request, not two: the caller sees only what its action returned, and the page updates around it. Nothing else is re-rendered — which is what the second input in AddOrder is there to prove. Type into it, place an order, and what you typed is still there.

section() is deliberately not a client module. The boundary it wraps things in is one, but the thing it wraps is a server component that fetches its own data, and a client reference cannot be async.

Three things follow from an action that revalidated, and none of them needs code:

  • What is re-rendered replaces what is on screen in place. revalidate('page') and revalidate('all') render the same tree a navigation to the page would, so a form’s state, a modal opened from onSuccess, a toast in the layout all survive. A one-time secret shown after the action is still there.
  • The re-render sees the write. A cache()d read the middleware or the action made before the write is forgotten before the page renders again, so a renamed row is renamed in the sidebar too, not only in the table.
  • What was fetched before the write is dropped. Payloads prefetched for other links and the pages held behind this one for the back button are from before the mutation. A visit to the list after creating a row fetches the list, with the row in it, rather than revealing the one prefetched a moment earlier. refresh() does the same.
  • What comes back is for the page it was invoked on. A tap that left the page while the action was in flight — Add to cart, then the brand link at once — changed what is showing, and the trees the host rendered for the page before are not put under the url after. The page on screen was fetched before the write, so it is asked for again whole, as refresh('all') would; a modal opened over the page in the meantime does not count as leaving it.

Building one with a schema and middleware

A bare "use server" function takes whatever it is given and defends itself by hand. createActionClient gives you a shape where the checking, the context and the types come from one declaration:

src/lib/action.tsts
import { createActionClient } from '@rsc-kit/core/action';

export const action = createActionClient({
  // Everything reaching here is a bug or an outage, and its message may name a
  // query, a path, a host. Say something fixed unless you raised it yourself.
  onError: (error) => (error instanceof AppError ? error.message : 'Something went wrong.'),
});
src/actions.tsts
'use server'

import { z } from 'zod';
import { action } from './lib/action';

export const createPost = action
  .use(async ({ next }) => next({ ctx: { user: await currentUser() } }))
  .use(async ({ ctx, next }) => {
    if (!ctx.user) throw new AppError('Sign in first');

    return next({ ctx: { audit: `user:${ctx.user.id}` } });
  })
  .input(z.object({ title: z.string().min(3), body: z.string().min(10) }))
  .handler(async ({ input, ctx }) => {
    return savePost({ ...input, authorId: ctx.user.id });
  });

input is typed from the schema and ctx from every middleware that ran, so the handler is checked against both without either being written twice. Add a .use() and the handler’s ctx grows; change the schema and the handler stops compiling.

Middleware runs outermost first and wraps what follows, so a step can time or clean up around the rest, not only check before it. Throw to refuse.

Return what next() gave you — it carries the value from everything inside. A step that neither calls next() nor throws is reported as a mistake, because a forgotten next() would otherwise look exactly like a check that passed.

Failures come back, they are not thrown

const result = await createPost({ title: 'x', body: 'y' });

result.data              // what the handler returned
result.validationErrors  // { title: ['Too short'] }
result.serverError       // 'Something went wrong.' - or a refusal's own message
result.refusal           // what refuse() carried beside it, typed - see below
result.redirected        // '/dashboard' - it redirect()ed

serverError is the backend’s own message when the backend turned the call down rather than failed: a refusal with a status (Go’s Refuse(503, "busy"), Laravel’s abort(429)), not signed in, not allowed, or nothing there. Only a failure is replaced by onError’s. A read built with .query() rejects with the same message, and a page that reads through it answers 401, 403 or its not-found page, as it would calling the backend directly.

Always an object. An action that redirect()s resolves with redirected set once the navigation is under way — the page is on its way elsewhere — and every other field reads undefined, never a TypeError inside a transition.

The argument is typed by the schema: createPost({ title: 1 }) fails the typecheck, and so does createPost() when the schema takes input. FormData is accepted beside it, which is how a form calls the same action.

Returned rather than thrown, and that is not a style choice: React serialises a rejected server action opaquely — production strips the message and leaves a digest — so a thrown validation error reaches the browser as “an error occurred” with the fields it named gone. A returned object crosses intact.

<Form> reads that shape directly, so there is nothing to wire:

<Form action={createPost} schema={schema}>

The schema then runs twice, in the two places it has to: in the browser so a mistake costs no round trip, and in the action because the action is a public endpoint reachable without the form.

From a button

Without a form, useAction is the transition around the call and the last result, with callbacks that say which way it went:

src/app/posts/archive-button.tsxtsx
'use client'
import { useOptimistic } from 'react'
import { useAction } from '@rsc-kit/core/useAction'
import { archivePost } from './actions'

export function Posts({ posts }) {
  const [shown, hide] = useOptimistic(posts, (list, id: number) => list.filter((p) => p.id !== id))
  const { execute, isPending } = useAction(archivePost, {
    optimistic: ({ id }) => hide(id),
    onError: (message) => toast.error(message),
  })

  return shown.map((post) => (
    <button key={post.id} disabled={isPending} onClick={() => execute({ id: post.id })}>
      Archive {post.title}
    </button>
  ))
}
  • execute takes what the action’s schema takes, and onSuccess is handed what its handler returned. executeAsync resolves with the result.
  • onError gets a serverError, or the first validation message, so a button with no field beside it still has something to show. onSettled runs after either.
  • optimistic runs inside the action’s transition, before the call — where React’s useOptimistic setter has to be. A failed action takes the change back on its own, because the optimistic state ends with the transition.
  • Neither call throws. Anything thrown on the way, the network included, becomes a serverError, so the transition always settles.
  • An action that redirected runs no callback and sets no state: the page is leaving.
  • status is "idle", "executing", "hasSucceeded" or "hasErrored", result is the last answer, and reset() forgets it. execute keeps one identity across renders, and reads the newest callbacks when it runs.

Failing on something a schema cannot know

.handler(async ({ input, fieldErrors }) => {
  if (await slugTaken(input.slug)) return fieldErrors({ slug: 'Already taken' });

  return save(input);
})

It arrives as validationErrors, on the field you named, exactly like a schema failure — so the form renders it in the same place with no extra handling.

fieldErrors comes in with the handler’s arguments rather than being imported, and that is what makes it typed: the field names are the schema’s, so fieldErrors({ slgu: … }) does not compile. There is nothing to pass — the handler already knows its input.

Write return fieldErrors(…). It throws either way, so nothing after it runs — but TypeScript cannot see that from a destructured argument. Without the return, a user you checked is still possibly undefined on the next line. With it, the type narrows and the code reads as what it is: this branch is over. A forgotten return is not a runtime bug; it is a type error that tells you to add one.

A string is one message; an array is several. The empty key is the whole submission, for a refusal that is about no field in particular:

return fieldErrors({ '': 'Sign-ups are closed for the weekend' });

Refusing, with something to act on

Sometimes the input is fine and the answer is still no: a project that cannot be deleted while orders are attached, a plan that does not include the feature. That is a refusal, not a validation error, and the page usually needs more than a sentence — the orders, as links.

const blockers = z.object({
  blockers: z.array(z.object({ id: z.number(), href: z.string() })),
});

export const deleteProject = action
  .input(z.object({ id: z.number() }))
  .refusal(blockers)
  .handler(async ({ input, refuse }) => {
    const attached = await ordersFor(input.id);

    if (attached.length) {
      return refuse('Still in use', {
        blockers: attached.map((o) => ({ id: o.id, href: `/orders/${o.id}` })),
      });
    }

    await removeProject(input.id);
  });
const result = await deleteProject({ id });

result.serverError       // 'Still in use'
result.refusal?.blockers // [{ id: 7, href: '/orders/7' }], typed from the schema
  • The message is always sent. refuse() is deliberate, so its message is never replaced by onError’s “Something went wrong.” It is the result’s serverError, which is where <Form>’s formError already reads.
  • The data is checked. It reaches the page only through the schema given to .refusal(), and is typed from it. Data that does not match is a bug in the server and is answered as one; data from an action that declares no schema is not sent, and the server’s log says so.
  • From anywhere. The handler’s refuse is typed to the schema; the exported refuse from @rsc-kit/core/action takes anything, for a middleware or a helper. A backend refuses the same way — Laravel’s Rsc::refuse(), Go’s RefuseWith — and its refusal arrives as the same result.refusal, so a delete the backend turns down at the last moment, because something was attached a second earlier, still shows its links.
  • One rule for a plain function, a generated stub included: anything that did not happen rejects, and only a redirect resolves. A refusal is an ActionRefusedError; an input the backend refused, by fieldErrors() or a ValidationException, is a ServerValidationError with .fieldErrors and .formErrors (both from @rsc-kit/core/errors). <Form> shows either as it always showed them, and useAction returns the fields as validationErrors. So isRedirected is the only check a caller needs on what resolved, and a navigation to the id it expected never runs on bad input.
  • Through a generated stub, <Form action={ordersDelete}>, the message arrives as formError, as the backend wrote it. Awaited directly, the stub rejects with an ActionRefusedError (from @rsc-kit/core/errors) carrying the message and the backend’s status, so a success toast or a navigation to the id it expected does not run for a write that did not happen; useAction reports it as serverError. A backend that turns the caller away is answered the same way, with its message and a status of 401 (not signed in), 403 (not allowed) or 404 (nothing there - Go’s Refuse(404, ...), Laravel’s abort(404), or the action’s own notFound()). An action has no not-found page, so a 404 is the message, never a 500. Only a redirect resolves, because the page is already on its way. The data does not travel: a plain function has no .refusal(schema) to check it against, so it is left out and the server’s log says so. To act on the data, call the backend with rpc() from an action built on createActionClient() and declare .refusal(schema).

In a form, formRefusal carries the data beside formError, and goes when it does:

<Form action={deleteProject}>
  {({ formError, formRefusal }) => (
    <>
      {formError && <p role="alert">{formError}</p>}
      {formRefusal?.blockers.map((b) => (
        <Link key={b.id} href={b.href}>Order {b.id}</Link>
      ))}
    </>
  )}
</Form>

formRefusal is typed from the form’s action — its .refusal(schema) — so there is nothing to cast. A component further down reads it with useForm<Values, Refusal>(), and useAction hands back the whole result, refusal included.

Signing someone in is a mutation whose entire result is a cookie, so an action can write one:

'use server';

import { cookies } from '@rsc-kit/core/request';

export async function login(formData: FormData) {
  const session = await authenticate(formData);

  (await cookies()).set('session', session.token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  });
}

It lands on the action’s own response, so the next navigation already carries it. delete expires one, which is what signing out is.

That get/set/delete trio is the whole surface an auth library needs, so one can be wired in without this package having an opinion about which. See Writing to the response for where else it works, and where it does not.

A message for the next page

An action’s return value goes back to the form that submitted it. When the action redirects, that form is gone, so “Saved.” on the list it lands on is a one-time message instead:

'use server';

import { flash } from '@rsc-kit/core/flash';
import { redirect } from '@rsc-kit/core/redirect';

export async function saveProduct(formData: FormData) {
  await products.save(formData);
  await flash('Product saved.');

  redirect('/products');
}
src/app/toasts.tsxtsx
'use client';

import { useFlash } from '@rsc-kit/core/useFlash';
import { toast } from 'sonner';

export function Toasts() {
  useFlash(({ message, kind }) => (kind === 'error' ? toast.error : toast.success)(message));

  return null;
}

Render <Toasts /> once, in the root layout. kind is 'success' (the default), 'error', 'info' or 'warning'; two calls are two messages.

flash() sets a cookie, so call it where a cookie can still be set: an action, middleware or a route.ts, not a page. The browser takes it, not the server: useFlash looks when it mounts, after every navigation and, where the browser supports CookieStore, whenever a response sets it, so an action that stays on the page shows its message too. Each message is handed over once, then cleared. A page never reads it, so a stored or edge-cached page stays cacheable. Unread, it lapses after a minute.

The cookie is readable by script, so it holds a message and never data. Render the message as text.

Work the visitor should not wait for

An audit row, a welcome email, a cache warm: the action’s answer should not wait for it, and it must still finish. after() queues work to run once the answer is on its way:

'use server';

import { after } from '@rsc-kit/core/request';

export async function signup(formData: FormData) {
  const user = await createUser(formData);

  after(() => sendWelcomeEmail(user));
  after(() => audit('signup', user.id));

  redirect('/welcome');
}

Why not a detached promise: on a long-lived process it happens to run to completion; on a Worker the isolate is torn down when the response ends unless the work was handed to the platform’s waitUntil, so a promise nobody awaited dies silently — some of the time, which is the worst way. after() is the same call on every host: handed to waitUntil where one exists, kept by the process elsewhere. A rejection is reported to the log and never reaches the response, which has already gone. It works from a component, a middleware.ts, an api route and an action alike; outside a request — a build — the work simply runs.

Two at once

Nothing queues them. Each call is an ordinary fetch, so two submits fired together really do overlap — they are not serialised behind one another.

That is a difference from Next, which runs one server action at a time per client — and it is worth being deliberate about, because Next’s queue was doing something for you that you may not have noticed.

A double-clicked “add to cart” sends two calls either way. Next runs them one after the other; here they run together.

So if a handler reads a value, changes it and writes it back, two of them can lose an update. That is ordinary database concurrency, which a per-client queue was quietly absorbing for you. Fix it where you would fix it anywhere else:

  • do the read and the write in one transaction
  • use an atomic update instead of read-modify-write
  • accept an idempotency key from the form

None of that is new advice. It just stops being optional.

The engine itself is safe under concurrency, and that part is tested rather than assumed: two actions running at the same moment each get their own request scope, so their cookies, headers and revalidation marks never reach each other’s response.

An action’s answer can carry a re-rendered region with it, and applying two of those is last-response-wins. Two actions that both revalidate('orders') will leave whichever response arrived last on screen, which is usually the one that committed last — but not necessarily, and nothing detects the difference.

Fire as many as you like when they touch different things. When several touch the same region, either let the last answer win or serialise them yourself.

<Form> gives you pending for that. It does not guard itself, so a double-click sends two requests unless you disable the button.

Errors

An action that fails does not answer with a Flight stream, so the client turns the response into an error before the decoder ever sees it:

Response What the client does What you do
X-RSC-Redirect header Starts the navigation and resolves with { redirected } Nothing — the page is on its way to the location, and there is nothing to catch.
422 Throws ServerValidationError, carrying errors Show the messages. <Form> does it for you.
Any other failure Throws Error, naming the status Whatever the app needs.
"use client";

import { ServerValidationError } from '@rsc-kit/core/errors';
import { createPost } from '../actions';

try {
  await createPost(title, body);
} catch (error) {
  if (error instanceof ServerValidationError) {
    // error.errors — { title: ['Too short'], body: ['Required'] }
  }
}

A redirect is treated as an instruction rather than something to display: an expired session answers that way, and the right response is the login page, not a message about one. See Validation for the form-shaped version of the same flow, which needs no try/catch at all.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close