AvalonAvalon
GitHub

Server Actions

Type-safe server functions with Zod validation, callable from the client via a typed proxy.

Server actions are type-safe server functions you call from the client without hand-writing an API route, fetch, or response validation. Define an action with defineAction, give it a Zod schema for input, and call it from the client through a typed proxy that returns a predictable { data, error } result. Forms work too, with progressive enhancement.

Defining actions

Create app/actions/index.ts (or src/actions/index.ts) and export a server object. Each action is created with defineAction:

// app/actions/index.ts
import { defineAction, ActionError } from "@useavalon/avalon/actions";
import { z } from "zod";

export const server = {
  greet: defineAction({
    input: z.object({ name: z.string().min(1) }),
    handler: async ({ name }) => {
      return { message: `Hello, ${name}!` };
    },
  }),

  // Nested namespaces become dotted names (e.g. "user.like").
  user: {
    like: defineAction({
      input: z.object({ postId: z.string() }),
      handler: async ({ postId }, ctx) => {
        const uid = ctx.cookies.get("uid");
        if (!uid) throw new ActionError({ code: "UNAUTHORIZED" });
        return { liked: postId };
      },
    }),
  },
};

Each handler receives the validated input and a context with the raw event, the web request, headers, and a cookies.get(name) helper.

Calling actions from the client

Import the typed actions proxy. Types are inferred from your server export automatically:

import { actions } from "virtual:avalon/actions";

const { data, error } = await actions.greet({ name: "World" });
if (error) {
  console.error(error.code, error.message);
} else {
  console.log(data.message); // typed
}

The proxy never throws for action failures — it always resolves to a { data, error } result. On success error is undefined; on failure data is undefined and error is an ActionError with a code, message, and (for validation failures) fields.

Explicit client (no virtual module)

If you prefer not to use the virtual import, build a typed client yourself:

import { createActionClient } from "@useavalon/avalon/actions";
import type { server } from "@/actions";

export const actions = createActionClient<typeof server>();

Progressive form enhancement

Set accept: "form" to parse multipart/form-data and application/x-www-form-urlencoded bodies. The form then works even without client JavaScript by posting directly to the endpoint:

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({ email: z.string().email() }),
    handler: async ({ email }) => ({ subscribed: email }),
  }),
};
<form method="POST" action="/_actions/subscribe">
  <input type="email" name="email" required />
  <button type="submit">Subscribe</button>
</form>

Errors

Throw an ActionError from a handler to return a structured failure:

throw new ActionError({ code: "FORBIDDEN", message: "Not allowed" });
CodeStatus
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_ALLOWED405
CONFLICT409
UNSUPPORTED_MEDIA_TYPE415
INTERNAL_SERVER_ERROR500

Validation failures return BAD_REQUEST with a fields map of field name → messages. Unexpected errors return INTERNAL_SERVER_ERROR; the message is included in development and hidden in production.

How it works

Actions are bundled into the Nitro server function and served from a single endpoint, POST /_actions/:name. The endpoint looks up the action by name, parses and validates the body, runs the handler, and returns JSON. Only actions present in your server export are callable — the client never executes arbitrary server code, and handler source never ships to the browser bundle.