Server Actions

← docs

Server Actions

Pages often need to mutate data — adding a record, toggling a flag, deleting a row. Server actions let you define those mutations as typed functions in your .server.ts file and call them from the client with a single hook or form component. No manual fetch wiring, no API route plumbing.

How it works

Define a serverActions map in your .server.ts file alongside the loader. Each action is wrapped with defineAction to carry its payload and result types.

On the server, the actionsHandler middleware exposes a single POST /__actions route that dispatches all calls. On the client, the Vite plugin replaces the serverActions import with a Proxy — each property access returns an ActionStub object that encodes the module and action name. Neither the function body nor its imports ever reach the browser bundle.

Defining actions

src/pages/movies.server.ts

import { getMovies } from '@/server/movies.js';
import { defineAction, type Loader } from '@hono-preact/iso';

const serverLoader: Loader<{ movies: MovieList }> = async () => {
  const movies = await getMovies();
  return { movies };
};

export default serverLoader;

export const serverActions = {
  addMovie: defineAction<{ title: string }, { ok: boolean }>(
    async (_ctx, payload) => {
      await db.insert({ title: payload.title });
      return { ok: true };
    }
  ),
};

defineAction is a no-op at runtime — it just returns the function unchanged. Its only job is to brand the function with phantom types so useAction and <Form> can infer the payload and result types without codegen.

Registering the handler

Register actionsHandler on your Hono app before the catch-all route:

src/server.tsx

import { actionsHandler, renderPage, location } from '@hono-preact/server';

app
  .post('/__actions', actionsHandler(import.meta.glob('./pages/*.server.ts')))
  .use(location)
  .get('*', (c) => renderPage(c, <Layout context={c} />, { defaultTitle: 'my-app' }));

actionsHandler accepts an import.meta.glob result — both lazy (default) and eager ({ eager: true }) globs work. It derives the module name from the filename by stripping the path prefix and .server.* extension.

Calling actions with useAction

useAction manages pending state, error handling, and optional cache invalidation after the action completes.

import { getLoaderData, type LoaderData, useAction } from '@hono-preact/iso';
import serverLoader, { serverActions } from './movies.server.js';

const Movies = ({ loaderData }: LoaderData<{ movies: MovieList }>) => {
  const { mutate, pending, error } = useAction(serverActions.addMovie, {
    invalidate: 'auto',
    onSuccess: (data) => console.log('added', data),
  });

  return (
    <>
      <button
        onClick={() => mutate({ title: 'Dune' })}
        disabled={pending}
      >
        {pending ? 'Adding...' : 'Add Movie'}
      </button>
      {error && <p>{error.message}</p>}
    </>
  );
};

Options

OptionTypeDescription
invalidate'auto' | false | string[]'auto' re-runs the page's serverLoader (via the /__loaders RPC in the browser). string[] invalidates named caches on other pages. Default: false.
onMutate(payload) => unknownCalled before the request fires. Return value is passed to onError as snapshot for optimistic rollback.
onSuccess(data) => voidCalled with the action's return value on success.
onError(err, snapshot) => voidCalled with the error and the onMutate snapshot on failure.
onChunk(chunk: string) => voidCalled for each chunk when the action returns a streaming response. See Streaming responses.

Return value

ValueTypeDescription
mutate(payload) => Promise<void>Fires the action. Stable reference — safe to pass to useEffect or memoized children.
pendingbooleantrue while the request is in flight.
errorError | nullThe last error, or null if none.
dataTResult | nullThe last successful result, or null. Not set for streaming responses.

Calling actions with <Form>

<Form> wraps useAction and handles FormData serialization. It disables all its inputs while the action is pending.

import { Form } from '@hono-preact/iso';
import { serverActions } from './movies.server.js';

const AddMovieForm = () => (
  <Form action={serverActions.addMovie} invalidate="auto">
    <input name="title" placeholder="Title" />
    <button type="submit">Add Movie</button>
  </Form>
);

<Form> accepts the same invalidate, onMutate, onSuccess, and onError options as useAction, plus any HTML <form> attribute except action and onSubmit.

FormData values are collected with Object.fromEntries(new FormData(form)). String values are passed as strings — coerce them in the action body if you need numbers or booleans. File inputs are handled automatically; see File uploads.

Optimistic updates

Use onMutate to update local state before the request fires, and onError to roll it back:

const [items, setItems] = useState(loaderData?.movies.results ?? []);

const { mutate } = useAction(serverActions.addMovie, {
  onMutate: (payload) => {
    const prev = items;
    setItems((cur) => [...cur, { id: 'temp', title: payload.title }]);
    return prev; // snapshot returned to onError
  },
  onError: (_err, snapshot) => {
    setItems(snapshot as typeof items); // restore on failure
  },
});

Cross-page cache invalidation

When a mutation on one page should refresh data on another, pass the target cache's name to invalidate:

// ratings.tsx — a different page that shows the movie list in a sidebar
const cache = createCache<{ movies: MovieList }>('movies'); // named cache

// movies.tsx — action that should also refresh the ratings sidebar
const { mutate } = useAction(serverActions.addMovie, {
  invalidate: ['movies'], // clears the 'movies' cache on success
});

Name a cache by passing a string to createCache. When an action specifies invalidate: ['movies'], the registry calls that cache's invalidate() and the next client navigation re-fetches.

invalidate accepts an array so you can clear multiple caches in one shot: invalidate: ['movies', 'ratings']. <Form> accepts the same option.

See the Server Loaders page for details on createCache naming.

File uploads

<Form> and useAction automatically switch to multipart/form-data when the payload contains File objects, so you can accept file uploads in your action without any extra configuration.

With <Form> — add a file input; <Form> detects it and sends FormData:

<Form action={serverActions.uploadPoster} onSuccess={({ url }) => setPosterUrl(url)}>
  <input type="hidden" name="movieId" value={movie.id} />
  <input type="file" name="poster" accept="image/*" />
  <button type="submit">Upload Poster</button>
</Form>

With useAction — include a File in the payload object:

const { mutate } = useAction(serverActions.uploadPoster);

const handleChange = (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (file) mutate({ movieId: movie.id, poster: file });
};

The action receives the File object directly:

export const serverActions = {
  uploadPoster: defineAction<{ movieId: string; poster: File }, { url: string }>(
    async (_ctx, { movieId, poster }) => {
      const url = await uploadToStorage(movieId, poster);
      return { url };
    }
  ),
};

Non-File values in a FormData payload are serialized as strings (numbers and booleans via JSON.stringify). Multi-value fields (e.g. checkboxes sharing a name) are collapsed to their last value — handle that case in your action body if needed.

Streaming responses

An action can return a ReadableStream for long-running operations. actionsHandler pipes the stream as text/event-stream. On the client, pass onChunk to useAction to receive each chunk:

// movies.server.ts
export const serverActions = {
  bulkImport: defineAction<{ url: string }, never>(async (_ctx, { url }) => {
    const source = await fetch(url);
    const encoder = new TextEncoder();
    return new ReadableStream({
      async start(controller) {
        let count = 0;
        for await (const item of parseNDJSON(source.body!)) {
          await saveMovie(item);
          count++;
          controller.enqueue(encoder.encode(JSON.stringify({ count }) + '\n'));
        }
        controller.close();
      },
    });
  }),
};
// movies.tsx
const [progress, setProgress] = useState(0);

const { mutate, pending } = useAction(serverActions.bulkImport, {
  onChunk: (chunk) => setProgress(JSON.parse(chunk).count),
  onSuccess: () => console.log('import complete'),
});

Each onChunk call receives a raw string chunk. Parse it as needed. onSuccess is called with undefined after the stream closes. data is not set for streaming actions.

Action guards

Actions can run middleware-style checks before dispatching — useful for authentication, authorization, or rate limiting. Export an actionGuards array from your .server.ts file:

// movies.server.ts
import { defineAction, defineActionGuard, ActionGuardError } from '@hono-preact/iso';
import type { Context } from 'hono';

export const actionGuards = [
  defineActionGuard(async ({ c }, next) => {
    const token = (c as Context).req.header('Authorization');
    if (!token) throw new ActionGuardError('Authentication required', 401);
    return next();
  }),
];

export const serverActions = {
  addMovie: defineAction<{ title: string }, { ok: boolean }>(
    async (_ctx, payload) => { /* ... */ }
  ),
};

Guards run before every action in the module. See Action Guards for the full API.

The server/client boundary

serverOnlyPlugin rewrites serverActions imports in the client bundle with a Proxy — each property access returns an ActionStub with the module and action name. Any imported actionGuards become empty arrays. serverLoaderValidationPlugin enforces that .server.* files only export serverGuards, serverActions, or actionGuards as named exports. See Overview — The server/client boundary for the full explanation.