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
| Option | Type | Description |
|---|---|---|
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) => unknown | Called before the request fires. Return value is passed to onError as snapshot for optimistic rollback. |
onSuccess | (data) => void | Called with the action's return value on success. |
onError | (err, snapshot) => void | Called with the error and the onMutate snapshot on failure. |
onChunk | (chunk: string) => void | Called for each chunk when the action returns a streaming response. See Streaming responses. |
Return value
| Value | Type | Description |
|---|---|---|
mutate | (payload) => Promise<void> | Fires the action. Stable reference — safe to pass to useEffect or memoized children. |
pending | boolean | true while the request is in flight. |
error | Error | null | The last error, or null if none. |
data | TResult | null | The 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.