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

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

// 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.

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, never wrong data). 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).
  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 async function createPost(input: unknown): Promise<ActionResult<Post>> {
  const user = await currentUser();
  if (!user) return { success: false, error: 'Not signed in', status: 401 };

  if (!input || typeof input !== 'object') {
    return { success: false, error: 'Invalid input', status: 400 };
  }
  const { title, body } = input as { title: string; body: string };
  if (!title) return { success: false, error: 'title is required', status: 400 };

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

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.

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..."}

Plain HTML forms as an alternative (the page action)

Server actions called via JS RPC are the right tool when you need typed return values back in the component (the createPost example above returns a Post object). For the simpler "submit form, server processes, render the result" flow, the framework's page action is the cleaner fit. A page.{js,ts} may export an action alongside its default render function. A non-GET/HEAD submission to that page's own URL runs the action (inside the page's segment middleware), so a plain <form method="POST"> works with JavaScript disabled AND through the client router, same UI either way. You write no route.ts and no hand-rolled new Response(...).

The action receives { request, params, searchParams, url, formData } (formData is the already-parsed body, request is the raw Request) and returns an ActionResult. The framework interprets the result.

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

// Runs only on the server. Receives the already-parsed formData.
export async function action({ formData }: { 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 createPost({ title, body });
  return { success: true, redirect: `/posts/${post.id}` };
}

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 method="POST">
      <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.

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.