Skip to content

Server Actions

Server actions are async functions that run exclusively on the server but can be imported and called from client-side web components as if they were local functions. WebJs rewrites the import at serve time so the browser receives a thin RPC stub instead of the real implementation. The result is full-stack type safety with zero manual API layer.

Defining a Server Action: the Two-Marker Convention

Server-side files use two complementary markers. The combination determines behaviour:

File'use server'?What it is
*.server.{js,ts}yesServer action. Source-protected AND RPC-callable: client imports are rewritten to RPC stubs that POST to /__webjs/action/<hash>/<fn>.
*.server.{js,ts}noServer-only utility. Source-protected; browser imports get a throw-at-load stub. Use for the Drizzle connection (db/connection.server.ts), session helpers, password hashing.
Plain .tsyesLint violation (use-server-needs-extension). The directive alone is silently ignored, and the file serves to the browser as plain source. Rename to add the .server. infix.
Plain .tsnoBrowser-safe; standard behaviour.

Bottom line: the .server.{js,ts} infix is the path-level boundary (the HTTP layer refuses to serve the source to the browser, full stop). The 'use server' directive is what registers the exported functions as RPC endpoints. You need both for a server action.

Server action (path boundary + RPC marker)

// modules/posts/actions/create-post.server.ts
'use server';
import { eq } from 'drizzle-orm';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';

export async function createPost(input: { title: string; body: string }) {
  const [post] = await db.insert(posts).values(input).returning();
  return post;
}

export async function deletePost(id: number) {
  await db.delete(posts).where(eq(posts.id, id));
  return { ok: true };
}

Server-only utility (path boundary, no RPC)

Drop the 'use server' directive when the file should be unreachable from the browser but is NOT called as a server action (the Drizzle connection, password hashing, helpers used by other server files). Browser imports throw at load time.

// db/connection.server.ts
import * as schema from './schema.server.ts';

const url = process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db';
const g = globalThis as unknown as { __webjs_db?: unknown };

async function open() {
  if ((globalThis as { Bun?: unknown }).Bun) {
    const { Database } = await import('bun:sqlite');
    const { drizzle } = await import('drizzle-orm/bun-sqlite');
    return drizzle({ client: new Database(url), relations: schema.relations });
  }
  const { DatabaseSync } = await import('node:sqlite');
  const { drizzle } = await import('drizzle-orm/node-sqlite');
  return drizzle({ client: new DatabaseSync(url), relations: schema.relations });
}

export const db = (g.__webjs_db ??= await open()) as Awaited<ReturnType<typeof open>>;

Do not import a server-only utility directly into a page, layout, or component. The stub throws when the module loads (not when called), so it works during SSR but crashes when a component hydrates or a page/layout module loads in the browser. Use server-only utilities inside server actions, route.{js,ts} handlers, or middleware (which only ever run on the server). A page reaches server logic by importing a 'use server' action instead: its RPC stub loads safely on the client and isn't even called there.

How the Import Rewrite Works

When the browser requests a server module's URL (e.g. /actions/posts.server.ts), the dev server intercepts the request. Instead of serving the real file (which contains database calls, secrets, etc.), it generates and serves a client stub:

// Generated by webjs (you never see this file)
import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';

async function __rpc(fn, args) {
  const body = await __wjStringify(args);
  // No CSRF token: the browser attaches Origin / Sec-Fetch-Site to a
  // same-origin POST itself, and the server verifies it.
  const res = await fetch('/__webjs/action/a1b2c3d4e5/' + fn, {
    method: 'POST',
    headers: { 'content-type': 'application/vnd.webjs+json' },
    credentials: 'same-origin',
    body
  });
  const ct = res.headers.get('content-type') || '';
  const text = await res.text();
  const parsed = ct.includes('application/vnd.webjs+json')
    ? __wjParse(text)
    : (ct.includes('application/json') ? JSON.parse(text) : text);
  if (!res.ok) {
    const msg = (parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status);
    throw new Error(msg);
  }
  return parsed;
}

export const createPost = (...args) => __rpc('createPost', args);
export const deletePost = (...args) => __rpc('deletePost', args);

The hash (a1b2c3d4e5) is a SHA-256 digest of the file's absolute path, computed when the action index is first built (lazily, on the first request, not at boot). The stub's function signatures match the original exports, so TypeScript sees the real types through the import.

Full-Stack Type Safety

Because the browser's import statement points at the real .server.ts file, TypeScript's language server resolves types from the original source. Your editor shows the correct parameter types, return types, and JSDoc comments. The rewrite happens only at runtime in the browser, so the type checker never sees the stub.

// components/post-form.ts
import { WebComponent, html, css } from '@webjsdev/core';
import { createPost } from '#actions/posts.server.ts';
//                        ^-- TS sees: (input: { title: string; body: string }) => Promise<Post>

export class PostForm extends WebComponent {
  static styles = css`:host { display: block; }`;

  async handleSubmit(e: Event) {
    e.preventDefault();
    const form = (e.target as HTMLFormElement);
    const title = (form.elements.namedItem('title') as HTMLInputElement).value;
    const body = (form.elements.namedItem('body') as HTMLTextAreaElement).value;
    const post = await createPost({ title, body });
    //    ^-- post is typed as Post, with real Date objects (not strings)
    this.dispatchEvent(new CustomEvent('created', { detail: post }));
  }

  render() {
    return html`
      <form @submit=${(e: Event) => this.handleSubmit(e)}>
        <input name="title" placeholder="Title" required />
        <textarea name="body" placeholder="Write something..." required></textarea>
        <button type="submit">Publish</button>
      </form>
    `;
  }
}
PostForm.register('post-form');

The Wire Format

Server actions use webjs's built-in serializer (a pure-ESM, dependency-free implementation in @webjsdev/core) for the RPC wire, not plain JSON.stringify. The content type is application/vnd.webjs+json. Rich JavaScript types survive the round trip:

  • Date objects: arrive as real Date instances, not ISO strings
  • Map and Set: preserved as their native types
  • BigInt: preserved (plain JSON throws on BigInt)
  • undefined: distinguished from null
  • NaN, Infinity, -0: preserved (plain JSON drops these)
  • Error: name + message + stack preserved
  • TypedArray (Int8...Float64), ArrayBuffer, DataView: inlined as base64
  • Blob, File, FormData: including binary content (file uploads through actions just work)
  • Symbol.for(...) registered symbols: preserved (local symbols throw a clear error)
  • Reference cycles + shared references: preserved by id
  • Nested combinations of all the above

The stub serialises function arguments with await stringify(args) and deserialises the response with parse(text). The server does the inverse. This is invisible to the developer, so you work with native types on both sides. The serializer is async because Blob/File/FormData require an await arrayBuffer(). For payloads without binary the async cost is just one Promise tick.

HTTP Verbs, Caching, and Streaming

A server action is a POST by default, but it can declare richer HTTP semantics through reserved sibling exports the framework reads statically, the same way a page declares export const revalidate. The call site never changes (you still write await getUser(7)); only the transport does. WebJs needs this where React and Next do not: WebJs has no server/client component split, so both reads and writes flow through the one action mechanism.

One callable function per configured file. A .server.ts file that declares any of these config exports must export exactly one callable. A second callable in the same file is a webjs check error.

Choosing the verb & Decision Guide

export const method selects the HTTP verb (absent means POST, so existing actions are unchanged):

  • Form-Bound Actions (<form action=${fn}>): MUST be POST (default, no method export or export const method = 'POST'). Never export export const method = 'GET' for a form action (triggers a 405 error and webjs check violation).
  • Programmatic / RPC Read Actions (Queries): Read-only calls (await getTodos()) SHOULD export export const method = 'GET'. Args ride URL query params (with POST fallback over 4KB). CSRF-exempt, supports ETags, 304 revalidation, and export const cache.
  • Programmatic / RPC Write Actions (Mutations): Data-modifying calls SHOULD use POST, PUT, PATCH, or DELETE. Pair with export const invalidates = (args...) => ['tag'] to evict cached reads.
// modules/users/queries/get-user.server.ts
'use server';
export const method = 'GET';
export async function getUser(id: number) {
  return db.query.users.findFirst({ where: { id } });
}

The verb changes the wire, not your code. A GET rides its arguments in the URL query (with an automatic POST fallback above a 4KB cap), is CSRF-exempt, and carries cache headers (below). A mutation (POST / PUT / PATCH / DELETE) sends the rich serialized body (DELETE rides the URL), is CSRF-protected, and reports cache invalidation (below). A request whose method does not match the declared verb gets a 405 with an Allow header.

Caching a GET (and invalidating it)

A GET action can opt into response caching, and a mutation can evict it by tag:

// a cached, tagged GET
'use server';
export const method = 'GET';
export const cache = 60;                          // seconds; or { maxAge, swr, public }
export const tags = (id: number) => ['user:' + id];
export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
// a mutation evicts the tags it touches
'use server';
export const invalidates = (id: number) => ['user:' + id];
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }

A cached GET carries Cache-Control plus a weak ETag (answering If-None-Match with a 304) and an X-Webjs-Tags header. When a mutation completes (it did not throw), the framework evicts its invalidates tags from the server cache and reports them via X-Webjs-Invalidate, so the client browser-cache coordinator revalidates a later read.

The cache export maps directly onto that Cache-Control header. The number shorthand (cache = 60) means { maxAge: 60 }. maxAge sets the freshness window in seconds (max-age). swr, also seconds, appends stale-while-revalidate: for that many seconds past expiry the browser keeps serving the cached response instantly while it revalidates in the background, and the weak ETag usually turns that background revalidation into a bodyless 304. public switches the scope from the default private (see Safety below). So cache = { maxAge: 60, swr: 300 } emits private, max-age=60, stale-while-revalidate=300. Reach for swr when a read should never make a visitor wait on the network but a briefly stale value is acceptable, which is most dashboard and listing reads.

Safety. cache with public: true SHARES one response across all users, keyed only by URL plus arguments. Use it ONLY for data identical for every visitor, never a session or per-user read. This is the same safety rule as a page's export const revalidate. The default (private) cache is per-response and safe.

Per-action middleware

export const middleware runs a chain around the action on BOTH the RPC and the route.ts boundary. Each entry is async (ctx, next) => result; it short-circuits by returning an ActionResult instead of calling next(), and accumulates context the action reads via actionContext() from @webjsdev/server, with no signature change to the action.

'use server';
import { requireAuth } from '#lib/mw.server.ts';
export const middleware = [requireAuth];
export async function deletePost(id: number) { /* ... */ }

Streaming results

An action that RETURNS a ReadableStream, an async iterable, or an async generator (any verb) streams its chunks over the single RPC response instead of buffering. The call site consumes it with for await:

// the action
'use server';
export async function* streamTokens(n: number) {
  for (let i = 0; i < n; i++) yield 'token ' + i;
}

// a component
for await (const chunk of await streamTokens(8)) {
  this.text.set(this.text.get() + chunk);
}

Each chunk is rich-serialized as it is yielded (back-pressure is respected, and the source generator is cancelled on a client disconnect or a superseded render). Detection is purely on the return value, so there is no config export. A streamed result is never cached, ETagged, or seeded; a mutation still emits X-Webjs-Invalidate. A mid-stream throw surfaces as an error from the iterable, sanitized the same way as a buffered throw (a generic message + a digest in production, never the raw message, since the 200 status is already sent).

Cancellation

An action reads the request's AbortSignal via actionSignal() (from @webjsdev/server) to stop work on a client disconnect or abort. On the client, a superseded async render() automatically ABORTS the previous render's in-flight action fetch, so a fast-typing user does not pile up stale requests. Outside an action, actionSignal() returns a never-aborting signal, so a direct server-to-server call stays safe.

No re-fetch on hydration (SSR seeding)

Each server-action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated stub reads that seed on its FIRST client call, so a shipping component does not re-issue the RPC on hydration. A later refetch or argument change still goes to the network. The seed is keyed by action hash plus function plus serialized arguments, consumed once, and fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced this page computed for that exact key, so it cannot disagree with the HTML on screen (a navigation evicts whatever the previous page left unconsumed); the one exception, an action returning two different results for the same arguments in one render, is warned about in development. Because a miss is otherwise invisible, dev also reports the seed counts on an X-Webjs-Seed header and logs one browser warning per page view when a hydration call missed AND the cause is provable (the page streams, the serializer dropped the block, or the page seeded that same action under different arguments); it stays silent otherwise, including on a page that emitted no seeds, where the header is the reliable signal. It is on by default; opt out with "webjs": { "seed": false } or WEBJS_SEED=0.

CSRF Protection

Every mutating server action RPC call (POST / PUT / PATCH / DELETE) is protected against Cross-Site Request Forgery by a cross-origin check, the model Remix 3 and Go 1.25 use. A GET action is CSRF-exempt (as noted above), since it is a read and does not mutate state. The check works as follows:

  1. The server reads Sec-Fetch-Site, a fetch-metadata header the browser sets on every request and page JavaScript cannot forge. same-origin and none (a direct navigation) pass.
  2. When Sec-Fetch-Site is absent (an older browser), it falls back to comparing the Origin host against the request host (honoring x-forwarded-host behind a proxy, unless WEBJS_NO_TRUST_PROXY=1).
  3. A cross-site request is rejected with a 403, unless its origin is listed in webjs.allowedOrigins (for reverse-proxy or multi-domain setups).

This works because a browser stamps a forged cross-site request with the attacker's origin (or Sec-Fetch-Site: cross-site), which fails the host match. There is no token, no cookie, and no server-side session store. A useful consequence: SSR responses carry no Set-Cookie, so a page that opts into a public Cache-Control is CDN-edge-cacheable.

REST Endpoints from Server Actions

A server action is RPC-callable from client components. To ALSO reach the same function over plain HTTP (from curl, a mobile app, or another service), put it behind a route.ts handler, the framework's first-class HTTP route. The action stays a normal 'use server' function; the route imports it and calls it.

// modules/posts/actions/create-post.server.ts
'use server';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';

export async function createPost({ title, body }: { title: string; body: string }) {
  const [post] = await db.insert(posts).values({ title, body }).returning();
  return post;
}
// app/api/posts/route.ts
import { createPost } from '#modules/posts/actions/create-post.server.ts';

export async function POST(req: Request) {
  const body = await req.json();
  const post = await createPost(body);
  return Response.json(post);
}

Now createPost is reachable two ways, both backed by the exact same function:

  • From a client component: import { createPost } from '.../create-post.server.ts' is auto-rewritten to an RPC stub, CSRF-protected, encoded with the WebJs rich serializer.
  • From curl or another service: POST /api/posts with a JSON body, served by the route.ts handler.

The route() Adapter

For the common case (merge query + route params + JSON body into one input object, run an optional validator, JSON-respond the result), the route() helper from @webjsdev/server writes that handler for you in one line. The hand-written route.ts above is always available for full control (custom headers, content negotiation, streaming).

The examples below import the action FUNCTION, which is the bare-function form: it applies only the middleware / validate you pass in route(fn, {'{'} middleware, validate {'}'}), because a function reference cannot reach its sibling config exports. To auto-apply the action's OWN declared middleware and validate, import the module namespace instead (import * as postActions from '.../create-post.server.ts') and pass that: export const POST = route(postActions), so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876).

// app/api/posts/route.ts (bare-function form)
import { route } from '@webjsdev/server';
import { createPost } from '#modules/posts/actions/create-post.server.ts';

export const POST = route(createPost);

// app/api/posts/[slug]/route.ts
import { route } from '@webjsdev/server';
import { getPost } from '#modules/posts/queries/get-post.server.ts';

// ctx.params.slug merges into the action input
export const GET = route(getPost);

Validating the Input

A boundary validator runs server-side before the action body. On the RPC path, declare it as the validate config export beside the action (issue #245). For a route() endpoint, pass it as the validate option. It receives the action's first argument and may throw to reject, return a structured { success: false, fieldErrors } envelope (a 422 over HTTP, a result.fieldErrors object over RPC), or return a transformed input value.

// modules/posts/actions/create-post.server.ts
'use server';
import { z } from 'zod';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(20000),
});

// the RPC boundary reads this config export
export const validate = CreatePostSchema.parse;

export async function createPost(input: { title: string; body: string }) {
  const [post] = await db.insert(posts).values(input).returning();
  return post;
}
// app/api/posts/route.ts: the REST boundary passes the same validator
import { route } from '@webjsdev/server';
import { createPost } from '#modules/posts/actions/create-post.server.ts';
import { z } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(20000),
});

export const POST = route(createPost, { validate: CreatePostSchema.parse });

When a thrown validator fails over HTTP, the response is 400 { "error": "...", "issues": [...] }. If the thrown error has an issues property (as zod and valibot errors do), it is passed through for structured client-side handling. A structured envelope failure is a 422.

CORS for a REST Endpoint

If your endpoint will be called from a different origin (e.g. a mobile app or a partner's frontend), wrap the route.ts handler in the cors() middleware from @webjsdev/server, or apply cors() in middleware.ts for the path. See the middleware docs.

Important: a route.ts REST endpoint is not CSRF-protected (only the internal RPC path is). A mutating REST endpoint is designed for external consumers who authenticate via bearer tokens, API keys, or signed requests. Add your own auth in a middleware or inside the function body.

ActionResult<T> Envelope Pattern

A recommended convention for server actions is to return a discriminated union instead of throwing errors:

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string; status: number };

export interface CreatePostInput { title: string; body: string }

export async function createPost(input: CreatePostInput): Promise<ActionResult<Post>> {
  const user = await currentUser();
  if (!user) return { success: false, error: 'Not signed in', status: 401 };

  // The parameter declares the contract; these checks ENFORCE it, because the
  // same action is reachable from a route.ts REST endpoint with a raw JSON body.
  const title = typeof input?.title === 'string' ? input.title.trim() : '';
  const body = typeof input?.body === 'string' ? input.body.trim() : '';
  if (!title) return { success: false, error: 'title is required', status: 400 };
  if (!body) return { success: false, error: 'body is required', status: 400 };

  const [post] = await db.insert(posts).values({ title, body, authorId: user.id }).returning();
  return { success: true, data: post };
}

Note the named input type. The action declares the contract it accepts, so the calling component type-checks against the real signature and a renamed field is a compile error on both sides. Typing the parameter unknown or any here would give that up and push a cast into every caller.

The declared type is a compile-time contract, not a runtime guarantee, so it does not replace validation. Keep the runtime checks (or declare export const validate, which runs on the RPC boundary before the action body and is the one place an untrusted payload is legitimately unknown) for every action a REST endpoint can also reach. The two are complementary: the type is what makes the call site safe, the check is what makes the wire safe.

This pattern makes error handling explicit on the client side without relying on try/catch. The caller checks result.success and branches accordingly. It also works naturally over a route.ts REST endpoint, where the caller receives the same JSON shape.

The envelope is additive, so beyond the two fields above a failure may also carry fieldErrors (per-field messages), values (what the user typed, so a re-render can repopulate the form), and a success may carry redirect (a same-site local path). The framework acts on fieldErrors and redirect itself. values it simply hands back to the page on ctx.actionData, so repopulating the inputs stays the page author's job (value=${actionData.values?.field}), which is what the validation example further down does.

Because of those extras, the framework treats a result as a FAILURE when result.success === false, OR result.fieldErrors is present, OR result.error is present and result.success is not true. Return an explicit success: false regardless. Not every consumer applies the full rule, optimistic() being the notable one that tests success === false alone, so an envelope carrying only fieldErrors would not register as a failure there.

Error Sanitisation in Production

When a server action throws (rather than returning an error envelope), WebJs catches the exception and returns a JSON error response:

  • Development: the response includes both the error message and the full stack trace, making debugging easy.
  • Production: the client gets a generic "Internal server error" message plus a short digest, never the raw thrown message (a DB-driver message, an internal IP, or a file path is not author-controlled) and never the stack. The full error is logged server-side keyed by the same digest, so a client report maps to the server log. A redirect() / notFound() control-flow throw passes through. To show the user a specific message, return an ActionResult { success: false, error } envelope instead of throwing.

This prevents accidental leakage of file paths, database schemas, or other implementation details to end users.

Complete Example

Here is the full flow from defining a server action to calling it from a component and exposing it over REST via a route.ts:

// 1. Define the server action
// actions/todos.server.ts
'use server';

interface Todo { id: number; text: string; done: boolean; createdAt: Date; }
const todos: Todo[] = [];
let nextId = 1;

export async function addTodo({ text }: { text: string }) {
  const todo: Todo = { id: nextId++, text, done: false, createdAt: new Date() };
  todos.push(todo);
  return todo;
}

export async function listTodos() {
  return todos;
}

// 1b. Expose them over REST with the route() adapter
// app/api/todos/route.ts
import { route } from '@webjsdev/server';
import { addTodo, listTodos } from '#actions/todos.server.ts';

export const GET = route(listTodos);
export const POST = route(addTodo);

// 2. Call from a web component
// components/todo-app.ts
import { WebComponent, html, css } from '@webjsdev/core';
import { addTodo, listTodos } from '#actions/todos.server.ts';

export class TodoApp extends WebComponent {
  static styles = css`
    :host { display: block; max-width: 400px; }
    ul { list-style: none; padding: 0; }
    li { padding: 8px 0; border-bottom: 1px solid #eee; }
    .date { color: #999; font-size: 12px; }
  `;

  todos = signal<{ id: number; text: string; createdAt: Date }[]>([]);

  async connectedCallback() {
    super.connectedCallback();
    const todos = await listTodos();
    this.todos.set(todos);
    // todos[0].createdAt is a real Date (the rich serializer preserved it)
  }

  async handleAdd(e: Event) {
    e.preventDefault();
    const input = this.shadowRoot!.querySelector('input') as HTMLInputElement;
    const todo = await addTodo({ text: input.value });
    this.todos.set([...this.todos.get(), todo]);
    input.value = '';
  }

  render() {
    return html`
      <form @submit=${(e: Event) => this.handleAdd(e)}>
        <input placeholder="What needs doing?" required />
        <button>Add</button>
      </form>
      <ul>
        ${this.todos.get().map(t => html`
          <li>
            ${t.text}
            <span class="date">${t.createdAt.toLocaleDateString()}</span>
          </li>
        `)}
      </ul>
    `;
  }
}
TodoApp.register('todo-app');

// 3. Call the REST endpoint from curl
// curl -X POST http://localhost:8080/api/todos \
//   -H 'Content-Type: application/json' \
//   -d '{"text":"Buy milk"}'
// => {"id":1,"text":"Buy milk","done":false,"createdAt":"2026-04-15T..."}

Binding an action to a form

Calling an action over RPC is the right tool when you need a typed return value back in the component (the createPost example above returns a Post object). For the "submit form, server processes, render the result" flow, bind the action straight into the form. The same imported function serves both, and the binding is the whole wiring: no route.ts, no hand-rolled new Response(...), no per-page adapter.

import { createPost } from '#modules/posts/actions/create-post.server.ts';

html`<form action=${createPost}> ... </form>`

The renderer omits the action attribute so the form posts to the page's own URL, supplies method="post" and an enctype, and emits one hidden __webjs_action field carrying the action's <hash>/<fn> identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. A submission carrying that identity runs the action inside the page's segment middleware, so the form works with JavaScript disabled AND through the client router, same UI either way.

A form-bound action always receives the FormData, which is where it differs from the same function called over RPC. validate is the typing seam: it takes the FormData, and its transform-return becomes the action's typed input.

// modules/posts/actions/create-post.server.ts
'use server';
export async function createPost(formData: FormData) {
  const title = String(formData.get('title') || '').trim();
  const body = String(formData.get('body') || '').trim();
  const values = { title, body };
  const fieldErrors: Record<string, string> = {};
  if (!title) fieldErrors.title = 'Title is required';
  if (body.length < 10) fieldErrors.body = 'Body is too short';
  if (Object.keys(fieldErrors).length) {
    return { success: false, fieldErrors, values, status: 422 };
  }
  const post = await db.insert(posts).values({ title, body }).returning();
  return { success: true, redirect: `/posts/${post[0].id}` };
}

// app/posts/page.ts
import { html } from '@webjsdev/core';
import { createPost } from '#modules/posts/actions/create-post.server.ts';

export default function NewPost({ actionData }: {
  actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
}) {
  const errors = actionData?.fieldErrors || {};
  const values = actionData?.values || {};
  return html`
    <form action=${createPost}>
      <input name="title" value=${values.title || ''} required />
      ${errors.title ? html`<p class="error">${errors.title}</p>` : ''}
      <textarea name="body" required>${values.body || ''}</textarea>
      ${errors.body ? html`<p class="error">${errors.body}</p>` : ''}
      <button>Publish</button>
    </form>
  `;
}

How the framework interprets the returned ActionResult:

  • Success ({ success: true, redirect? }, or any non-failure result) is a 303 See Other to result.redirect if present, else the page's own path (Post/Redirect/Get, so a reload does not resubmit). result.redirect must be a same-site local path (a single leading /); for a real external redirect throw redirect(absoluteUrl).
  • Failure ({ success: false }, or a fieldErrors, or an error) re-SSRs the SAME page with status (default 422) and the result on ctx.actionData. The page reads actionData.fieldErrors.<name> for messages and actionData.values.<name> to repopulate native <input value=...>, so the user's typed input survives.
  • No identity (a bare <form method="post"> that binds nothing) is a 405 with Allow: GET, HEAD. There is no page action export to fall back to.
  • An identity that no longer resolves is a 422 re-render carrying a resubmit message and the submitted values. That is a form held open across a deploy; a 404 would discard what was typed and a silent success would report a write that never happened.

Everything the action declares applies here too, or an action would be protected over RPC and open over a form: validate runs on the submitted FormData, the middleware chain runs (with the page's params / searchParams / url on ctx), and invalidates is evicted when the action actually ran. The submission is Origin-verified exactly like an RPC call, so a no-JS form needs no CSRF token field. An action declaring method = 'GET' cannot be bound to a form (it rides its arguments in the URL and skips the CSRF check), which is a 405 at runtime and the form-action-not-a-get-action error in webjs check. A streamed return is refused on this path: a submission is answered with a redirect or a page, and with JS off there is no consumer for frames.

A form whose buttons run different actions binds each one on its submitter, with the same unquoted spelling one level down: <form action=${saveDraft}>…<button formaction=${publishPost}>Publish</button></form>. The identity rides the pressed button's own name/value pair, which a browser submits for that button alone, so no formaction url is emitted and the whole thing works with JavaScript off. Both identities reach the server and the LAST wins, which is the submitter's whenever one was pressed.

A bound submitter carries its whole submission, so the enclosing form does not have to be bound. The renderer puts formmethod="post" and formenctype="multipart/form-data" on the button itself, alongside the identity, which is what React does for a button carrying a function formAction. So a per-button action works inside a bound form, an unbound form, a method="get" form, or a form with no method at all. No formaction url is emitted, because an empty one is an HTML conformance error, so the submission targets whatever the form targets: a form declaring its own action="/x" sends its buttons there, still running the action, since the identity travels in the body.

The submitter must be a <button>, and it cannot carry its own name, value, form, or a static formaction, because the identity already occupies that name/value pair. Two input controls are refused for the same underlying reason. <input type="image"> submits name.x / name.y coordinates rather than name=value, so the identity would never arrive. <input type="submit"> would receive the identity in its value, which on that control is also the visible caption, so it would render captioned with the action id and the only way to label it is the channel the identity needs. A <button> avoids both, because its label is its children. A .prop spelling of any of these (.name, .value, .formAction, .formMethod, .formEnctype) is refused as well: all reflect on a submitter, so the write is dropped at SSR and lands in the attribute in the browser. Separately, a BOUND submitter is refused when it declares a formmethod other than post, an unparseable formenctype like text/plain, or formmethod="dialog": each contradicts the action attached to that same button. A button that binds NOTHING is left alone, whatever it declares. Its formmethod / formenctype is a legal native override, the author wrote it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time when a submission is carrying an identity it cannot deliver.

With JavaScript off this is a native round-trip (the browser submits, follows the 303, or renders the 422). With JavaScript on the client router applies the 422 in place (no reload, typed input preserved) and follows the 303 via fetch. Both ends of the progressive-enhancement spectrum from one piece of code, no form library. See the client router docs for the rendering behavior, and progressive enhancement for the full write-path pattern.

To make that same form feel instant without changing the action, see Optimistic UI.