Server Actions

Write a mutation as a server function, call it from the client, and revalidate the cache it affects: type-checked, CSRF-protected, and served at one endpoint.

Defining an action

Wrap a server function with action(). Give it a stable id so the client can dispatch it, and optionally declare what to revalidate on success.

// src/actions/posts.js
import { action } from 'what-framework/server';

export const createPostAction = action(
  async ({ title, body }) => {
    const { createPost } = await import('../db.js');
    const post = createPost({ title, body });
    return { ok: true, slug: post.slug };
  },
  { id: 'createPost', revalidate: ['/'] }
);

The client imports this module to dispatch the action, and nothing strips the server half of it out of the client bundle: there is no 'use server' directive. Bundle this module for the browser and the action body ships with it, along with every module it imports, dynamically or not. Never put an API key, a database password, or any other secret in an action module or in anything it imports.

Serving actions

Actions are dispatched over POST /__what_action. The deploy adapters mount this for you; importing the action module (e.g. from your routes file) registers it. With a manual server, mount the adapter for your runtime:

import { nodeActionMiddleware, fetchActionHandler } from 'what-framework/server';

// The session token to compare the request's against. This is what the
// deploy adapters do: read the what-csrf cookie they set on every response.
// Node hands you a headers object, fetch runtimes hand you a Headers.
function getCsrfToken(req) {
  const hdrs = req.headers;
  const raw = typeof hdrs?.get === 'function' ? hdrs.get('cookie') : hdrs?.cookie;
  const m = String(raw || '').match(/(?:^|;\s*)what-csrf=([^;]+)/);
  return m ? decodeURIComponent(m[1]) : null;
}

// Node (connect / express). Reads and parses the body for you, 1 MB cap.
app.use(nodeActionMiddleware({ getCsrfToken }));

// Fetch runtimes (Workers, Deno, Bun): (Request) => Response
const handler = fetchActionHandler({ getCsrfToken });

Both wrappers build on createActionHandler, which is runtime-neutral: it takes an already-parsed { method, headers, body, query } and returns { status, headers, body }. It reads no request stream, so it is not mountable on its own. Reach for it only when you are wiring a server the two adapters do not cover, and read and parse the body yourself.

Calling from the client

The endpoint reads the action id and the CSRF token as hidden fields. Attributes on the <form> element are never submitted, so a form that carries the id as data-action="createPost" and nothing else answers 400. <Form> emits both fields for you and renders a real <form method="post">, which submits with no JavaScript at all:

// src/components/new-post-form.js
import { h } from 'what-framework';
import { Form } from 'what-framework/server';
import { createPostAction } from '../actions/posts.js';

export function NewPostForm({ csrfToken }) {
  return h(Form, { action: createPostAction, csrfToken, redirect: '/' },
    h('input', { name: 'title', required: true }),
    h('textarea', { name: 'body', required: true }),
    h('button', {}, 'Publish'),
  );
}

That renders hidden _action, what-csrf-token and _redirect inputs, then your children. Pass the action function, not its id string, so the two can never drift apart. On success the endpoint answers 303 and the browser follows _redirect.

On the client, <Form> finds the token itself (the what-csrf-token meta tag, else the what-csrf cookie). On the server there is no document, so a server-rendered form must be handed csrfToken explicitly; without it the double-submit check rejects the POST and nothing on the page says why. Note that cached pages (mode: 'static' or 'hybrid') deliberately carry no per-visitor token, because that HTML is shared between visitors: a form there still works with JavaScript on, since the enhancer falls back to the cookie, but it cannot submit with scripting off. Put those forms on a mode: 'server' page.

Or call the action and use its return value. On the client the wrapper posts to the same endpoint and attaches the token from the page:

import { createPostAction } from '../actions/posts.js';

const { slug } = await createPostAction({ title, body });

A hand-rolled fetch has to supply everything the wrapper does: the X-What-Action header, a JSON body shaped { args: [...] }, credentials: 'same-origin', and the X-CSRF-Token header. Omit the token and the endpoint answers 403.

Revalidating after a mutation

The revalidate / revalidateTags options fire after the action resolves, purging the origin ISR cache (and any CDN) so the next request re-renders with fresh data. Two conditions apply. They run only on the dispatched path, so calling the action function directly on the server (from a loader, a route, or another action) runs the function and revalidates nothing. And they need a bound cache engine: the deploy adapters bind whichever what-isr engine you hand them, and a manual server binds one itself with setRevalidationHandler(engine). Unbound, both helpers are no-ops that log a dev warning. For the direct-call case, purge by hand:

import { revalidatePath, revalidateTag } from 'what-framework/server';

await revalidatePath('/');          // purge one path
await revalidateTag('posts');       // purge everything tagged 'posts'

Two revalidates, on purpose

revalidatePath/revalidateTag (cache) are distinct from invalidatePath (in-memory pub-sub). All three come from what-framework/server, not the router. The first pair purges the rendered cache through the bound engine; invalidatePath calls the callbacks you registered with onRevalidate(path, cb), and nothing else. See Caching & ISR.

CSRF & error masking

The handler validates a CSRF token by default (inject one via getCsrfToken; a meta tag is emitted into uncached documents). It fails closed: a missing or bad token is rejected. Fetch clients send it as the X-CSRF-Token header, plain form posts as the _csrf or what-csrf-token field, and the header wins when both are present. Thrown errors are masked to a generic 500 so internal details never reach the client, and the real error is logged server-side.

An action returns a value; it does not own the HTTP response. There is no channel for it to set headers, so anything that must issue Set-Cookie (sign-in, sign-out, session rotation) belongs in a normal route handler, not an action.