hono-preact

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 a form or a hook. No manual fetch wiring, no API route plumbing.

For long-running operations that emit progress or results incrementally, see Streaming.

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.

Actions are invoked via POST to the owning page's URL. 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 (assumes type MovieList = { results: { id: number; title: string }[] }):

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

const serverLoader = 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.

defineAction options

Pass a second argument to defineAction(fn, opts) to configure per-action behavior:

OptionTypeDefaultDescription
useActionUsenonePer-action middleware and stream observers.
timeoutMsnumber | false30000Per-action deadline; false disables it.

The first argument is ctx: ActionCtx, which has two fields: signal (an AbortSignal tied to the HTTP request) and c (the request's Hono Context). Use ctx.c to read cookies (getCookie), set response headers, or reach Hono Bindings via ctx.c.env.

Registering the handler

You do not register the action handler directly. The framework's Vite plugin generates the server entry, which mounts a page POST handler alongside the loader RPC and SSR catch-all, and exports it as the worker's default. The handler routes by mod.__moduleKey (the path-derived key injected into each .server.* file by moduleKeyPlugin).

If you ever need a custom server entry, see renderPage for the manual wiring contract.

Calling from a form: <Form action={stub}>

<Form action={stub}> is the primary way to invoke an action. Pass the action stub directly to the action prop. On the client, <Form> intercepts the submit event, serializes the form fields, and fires a JSON POST to the page URL. Without JS, the form falls back to a native POST to the same URL and the page re-renders with the action result available via useActionResult().

import { definePage, Form } from 'hono-preact';
import { serverLoaders, serverActions } from './movies.server.js';

const dataLoader = serverLoaders.default;

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

const MoviesView = dataLoader.View(({ data }) => (
  <main>
    <AddMovieForm />
    <ul>
      {data.movies.results.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  </main>
));

export default definePage(MoviesView);

<Form> accepts any HTML <form> attribute (except onSubmit, which it owns), plus action (required). The wrapping <fieldset> is disabled while the submission is in flight.

FormData serialization

FormData values are collected into a plain object before being passed to the action. Single-value fields arrive as scalars (string for text inputs, File for file inputs). Repeated field names (checkboxes sharing a name, multi-select, <input type="file" multiple>) arrive as arrays in the order they appeared in the form. Declare the array fields in your defineAction payload type:

defineAction<{ title: string; tags: string[]; photos: File[] }, ...>

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.

Progressive enhancement (PE): forms without JS

<Form action={stub}> works without JavaScript. On a no-JS page load the form submits as a standard HTML POST to the page URL. The server runs the action, then re-renders the page. Inside the rendered page, useActionResult(stub?) returns the outcome of that action call so you can show validation errors or a success message without requiring JavaScript.

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

const AddMovieForm = () => {
  const result = useActionResult(serverActions.addMovie);

  return (
    <Form action={serverActions.addMovie}>
      {result?.kind === 'deny' && <p role="alert">{result.message}</p>}
      {result?.kind === 'deny' && result.data?.fieldErrors?.title && (
        <p>{result.data.fieldErrors.title}</p>
      )}
      <input
        name="title"
        placeholder="Title"
        defaultValue={result?.submittedPayload?.title ?? ''}
      />
      <button type="submit">Add Movie</button>
    </Form>
  );
};

useActionResult(stub?) returns the result of the most recent action invocation that targeted the current page render. The submittedPayload field lets you re-populate form inputs via defaultValue so users don't lose what they typed on a deny.

Pass no argument (or pass undefined) to receive the result of any action that posted to this page. Pass a specific stub to filter to that action only.

Returning structured deny data

Use deny() with the data option to pass field-level error information back to the form:

import { defineAction, deny } from 'hono-preact';

export const serverActions = {
  addMovie: defineAction<{ title: string }, { ok: boolean }>(
    async (_ctx, payload) => {
      if (!payload.title.trim()) {
        throw deny(422, 'Validation failed', {
          data: { fieldErrors: { title: 'Title is required' } },
        });
      }
      await db.insert({ title: payload.title });
      return { ok: true };
    }
  ),
};

deny(status, message, opts?) accepts an optional third argument: opts.data is any value serializable to JSON. It is available as result.data in useActionResult() after a deny.

Calling programmatically: useAction(stub)

useAction manages pending state, error handling, and optional cache invalidation after the action completes. Use it for programmatic mutations: button onClick handlers, conditional logic before submitting, or any case where you need to await the result.

import { useAction } from 'hono-preact';
import { serverLoaders, serverActions } from './movies.server.js';

const moviesLoader = serverLoaders.default;

const Movies = () => {
  const { movies } = moviesLoader.useData();
  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>}
    </>
  );
};

useAction posts to the page URL with Accept: application/json and returns a discriminated result. The action body runs identically whether called from <Form> or useAction.

Options

OptionTypeDescription
invalidate'auto' | false | LoaderRef<unknown>[]'auto' re-runs the current page's serverLoader (via the /__loaders RPC in the browser). An array of LoaderRefs invalidates each loader's cache; pass loaders imported from other pages to refresh data across the app. 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<{ ok: true; data: TResult } | { ok: false; error: Error }>Fires the action. Resolves with a discriminated union so awaiting callers can chain on the result without leaking unhandled rejections. The same error is also written to the error state field for non-awaiting render-time use. 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.

Chaining on success

const { mutate } = useAction(addMovie);

async function submit(payload) {
  const result = await mutate(payload);
  if (result.ok) {
    navigate(`/movies/${result.data.id}`);
  } else {
    showToast(result.error.message);
  }
}

Non-awaiting callers (the common case: onClick={() => mutate(payload)}) still get the existing error state and onError callback for failure handling; no unhandled rejection is produced.

Pending state outside the form

useFormStatus(stub?) reports whether a submission is currently in flight. It is JS-only (SSR always returns { pending: false }). Use it for indicators outside the form's <fieldset>, such as a global spinner or a disabled navigation item.

import { useFormStatus } from 'hono-preact';
import { serverActions } from './movies.server.js';

const SaveIndicator = () => {
  const { pending } = useFormStatus(serverActions.addMovie);
  return pending ? <p>Saving...</p> : null;
};

Pass no argument to track any in-flight form submission on the page. Pass a specific stub to track only that action.

Optimistic updates

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

const { movies } = moviesLoader.useData();
const [items, setItems] = useState(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
  },
});

See Optimistic UI for the higher-level useOptimisticAction hook, which also integrates directly with <Form action={result}>.

Cross-page cache invalidation

When a mutation on one page should refresh data on another, import the other page's loader and pass it to invalidate. Invalidation is by reference; there are no cache names:

// movies.tsx: an action that should also refresh the ratings sidebar
import { useAction } from 'hono-preact';
import { serverLoaders as ratingsLoaders } from './ratings.server.js';
import { serverActions } from './movies.server.js';

const ratingsLoader = ratingsLoaders.default;

const { mutate } = useAction(serverActions.addMovie, {
  invalidate: [ratingsLoader], // clears ratingsLoader's cache on success
});

invalidate accepts an array of LoaderRefs, so a single action can refresh multiple loaders in one shot: invalidate: [moviesLoader, ratingsLoader].

Use 'auto' (instead of an array) to invalidate the current page's own loader after the action succeeds.

Method form: stub.useAction(opts)

useAction(stub, opts) and stub.useAction(opts) are equivalent. Both ship; pick whichever reads better in context.

import { useAction } from 'hono-preact';
import { serverLoaders, serverActions } from './movies.server.js';

const moviesLoader = serverLoaders.default;

// Method form:
const { mutate, pending } = serverActions.addMovie.useAction({
  invalidate: [moviesLoader],
});

// Equivalent function form:
const { mutate, pending } = useAction(serverActions.addMovie, {
  invalidate: [moviesLoader],
});

The method form reads more naturally when the action is the focus of the call site; the function form reads better when grouped with other hooks at the top of a component.

File uploads

useAction automatically switches to multipart/form-data when the payload contains File objects. Since <Form> serializes file inputs as File instances in the payload, file uploads work transparently when you pair it with useAction.

With <Form>: add a file input; the framework detects the File value in the payload and sends FormData:

const UploadPosterForm = ({ movieId, setPosterUrl }) => (
  <Form action={serverActions.uploadPoster}>
    <input type="hidden" name="movieId" value={movieId} />
    <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). Repeated field names (multi-checkbox groups, multi-select, <input type="file" multiple>) are forwarded as arrays; type your action's payload accordingly (tags: string[], photos: File[], etc.) instead of expecting a single value.

Streaming responses

An action can be a streaming generator for long-running operations. Streaming actions must be invoked via useAction; they cannot be used with <Form action={stub}> (a type error). See Streaming: limitations for details.

// movies.server.ts
export const serverActions = {
  bulkImport: defineAction(async function* (_ctx, payload: { url: string }) {
    const source = await fetch(payload.url);
    let count = 0;
    for await (const item of parseNDJSON(source.body!)) {
      await saveMovie(item);
      count++;
      yield { count };
    }
    return { imported: count };
  }),
};
// movies.tsx
const [progress, setProgress] = useState(0);

const { mutate, pending } = useAction(serverActions.bulkImport, {
  onChunk: (p) => setProgress(p.count),
  onSuccess: (r) => console.log(`imported ${r.imported}`),
});

Each onChunk call receives a typed chunk. onSuccess receives the typed final result. data holds the final result after the stream closes.

Gating actions with middleware

Actions accept the same use array as loaders and pages. Use it for authentication, authorization, rate limiting, or any other gate that should fire before the action body runs:

// movies.server.ts
import { defineAction, defineServerMiddleware, deny } from 'hono-preact';

const requireAuth = defineServerMiddleware<'action'>(async (ctx, next) => {
  const token = ctx.c.req.header('Authorization');
  if (!token) throw deny(401, 'Authentication required');
  await next();
});

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

For module-wide gating (every action plus every loader), export pageUse from the .server.ts file instead. See Middleware for the full chain model.

Timeouts

Actions get a deadline. By default every call has 30 seconds to finish; the deadline starts when the handler receives the request. Pass timeoutMs on defineAction to override:

export const slowExport = defineAction<{ id: string }, { ok: boolean }>(
  async (_ctx, { id }) => {
    /* ... */
  },
  { timeoutMs: 60_000 }
);

Pass timeoutMs: false to opt out entirely (useful for streaming actions):

export const longRunningStream = defineAction(
  async function* (_ctx, payload: { url: string }) {
    /* returns a stream that may run for minutes */
  },
  { timeoutMs: false }
);

When a deadline fires, the action's ctx.signal aborts with reason DOMException('TimeoutError'). The server responds with status 504 and a { __outcome: 'timeout', timeoutMs } envelope. On the client, the failure surfaces as a TimeoutError instance (with kind: 'timeout' and the original timeoutMs as class properties).

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 pageUse / loaderUse / actionUse named exports become empty arrays on the client. serverLoaderValidationPlugin enforces that .server.* files only export serverLoaders, serverActions, or the recognized use exports. See Overview: The server/client boundary for the full explanation.

Security: SameSite cookies on form posts

Form posts go to the page URL on the same origin. The framework relies on SameSite=Lax (Hono's cookie default) for CSRF protection. Cross-origin POSTs without a credential do not carry the session cookie; cross-origin POSTs from a malicious site cannot read the response.

If you need a stricter posture, mount Hono's CSRF middleware app-wide via appConfig.use or scope it to specific paths in your src/api.ts. See CSRF Protection for the full recipe.