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.
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.
Typing the payload#
defineAction and serverRoute(r).action accept the payload type three ways; the explicit generics above are only one of them:
-
Explicit generics —
defineAction<Payload, Result>(fn), as shown above. -
Annotated parameters — annotate the handler's payload argument and return type and drop the generics; the ref's types are inferred from the function:
addMovie: defineAction( async (_ctx, payload: { title: string }): Promise<{ ok: boolean }> => { await db.insert({ title: payload.title }); return { ok: true }; } ); -
A validation schema — pass a Standard Schema as
input; the payload type is the schema's validated output, inferred with no generics, and enforced at runtime before the handler runs:addMovie: defineAction(async (_ctx, payload) => db.insert(payload), { input: NewMovieSchema, // payload is InferOutput<typeof NewMovieSchema> });
With none of these, payload is typed unknown (never any), so the type system makes you narrow it before use rather than letting an untyped value through.
Only the schema form validates at runtime. The two type-only forms (explicit generics, annotated parameters) assert a shape the wire does not enforce: the client payload reaches the handler as-is. In dev, the server warns once per action when an action without an input schema receives a payload other than undefined, null, or an empty object.
defineAction options#
Pass a second argument to defineAction(fn, opts) to configure per-action behavior:
| Option | Type | Default | Description |
|---|---|---|---|
use | ActionUse | none | Per-action middleware and stream observers. |
timeoutMs | number | false | 30000 | Per-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.
Route-bound actions#
Like loaders, an action can be bound to its route with serverRoute(r). Calling .action(fn) is otherwise identical to defineAction: the action body's ctx still carries no typed location and reads its data from the payload, while the route's guard chain gates the request with a route-authoritative location matched against this route's pattern (see Middleware). It accepts the same options (use, timeoutMs, input) and types the payload the same three ways too (see Typing the payload); the example below infers it from an input schema, so no <Payload, Result> generics are needed:
import { serverRoute } from 'hono-preact';
import { RateSchema } from './movie-schema.js';
const route = serverRoute('/movies/:id');
export const serverActions = {
rate: route.action(
async (_ctx, payload) => {
// payload is InferOutput<typeof RateSchema>: { movieId: string; stars: number }
await db.rate(payload.movieId, payload.stars);
return { ok: true };
},
{ input: RateSchema }
),
};
Binding the route makes the action resolve its page-level use chain from that exact route pattern. A bare defineAction is route-independent (like a bare defineLoader): its POST RPC runs only the app-level use and the action's own unit-level use, never a route node's page-layer use. So reach for route.action whenever an action lives under an authenticated layout, so the route-level gate (a requireSession on the parent route node, say) is applied by the action's own declared pattern. Otherwise put the gate on the action itself with a unit-level use. The same route binder typically already defines the page's loaders, so its loaders and actions read as one route-bound unit.
Bind an action to the route its module is registered on. Route-binding pins the use chain to the declaring route's gates, so the framework enforces that a route-bound action (or loader) names the route it is actually mounted on: a mismatch fails the request closed with a startup error rather than silently resolving the wrong route's gates. Using the same route binder for a module's loaders and actions keeps this correct by construction.
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 }) => {
if (!data) return <p>Loading...</p>;
return (
<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.
Form lifecycle#
<Form> accepts optional callbacks that fire after a submission resolves, plus
declarative cache invalidation and form reset:
| Prop | Type | Notes |
|---|---|---|
onSuccess | (data, { reset }) => void | Fires on a successful action. reset() clears the form; reset(names) clears specific fields. |
onError | (err: Error) => void | Fires on an error, timeout, or unknown outcome. A deny is not an error; read it via useActionResult. |
invalidate | 'auto' | false | LoaderRef[] | Same semantics as useAction's invalidate: 'auto' re-runs the active loader; an array clears each (and re-runs the active loader if listed). |
reset | boolean | Reset the form to its defaults after a successful submit. |
<Form
action={serverActions.createIssue}
reset
invalidate="auto"
onSuccess={() => setShowForm(false)}
>
<input name="title" />
<button type="submit">Create</button>
</Form>
reset calls the form's native reset (restoring uncontrolled fields to their
defaults and firing a reset event). Controlled custom fields can subscribe to
that event to reset themselves.
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.
Deny codes#
deny() accepts a named code string instead of a numeric status. Codes map to standard HTTP statuses by default, but the numeric status is always authoritative when both are given:
import { deny } from 'hono-preact';
// Code only: status inferred from the code (404)
throw deny('NOT_FOUND');
// Code with a message
throw deny('FORBIDDEN', 'Members only');
// Code with structured data; status inferred from the code (409)
throw deny({ code: 'CONFLICT', data: { field: 'title' } });
// Explicit status overrides the code's default
throw deny({ status: 403, code: 'UNAUTHORIZED' });
// Numeric status form still works exactly as before (no code attached)
throw deny(404, 'Not found');
Code to status defaults#
The DENY_CODE_STATUS constant maps each code to its default HTTP status. Import it when you need to look up the status for a given code at runtime:
import { DENY_CODE_STATUS } from 'hono-preact';
const status = DENY_CODE_STATUS['NOT_FOUND']; // 404
| Code | Default status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHORIZED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
UNPROCESSABLE | 422 |
TOO_MANY_REQUESTS | 429 |
INTERNAL | 500 |
Reading the code client-side#
useActionResult() exposes result.code when the action called deny() with a code. Switch on it instead of comparing message strings:
import { useActionResult } from 'hono-preact';
import { serverActions } from './movies.server.js';
const ActionFeedback = () => {
const result = useActionResult(serverActions.addMovie);
if (result?.kind !== 'deny') return null;
switch (result.code) {
case 'NOT_FOUND':
return <p>Movie not found.</p>;
case 'FORBIDDEN':
return <p>You do not have permission.</p>;
case 'CONFLICT':
return <p>A movie with that title already exists.</p>;
default:
return <p>{result.message}</p>;
}
};
result.code is DenyCode | undefined. It is undefined when the action used the numeric form deny(status, message).
Deny codes are on the action path only. Loader denies surface as an Error without a structured code, so result.code is not available from loader error boundaries.
Error handling#
An action that throws anything other than a framework outcome (deny, redirect, a timeout) responds 500 with the uniform error envelope. In production the envelope's message is always Action failed: a thrown error's text can carry PII or internal detail, so it never reaches the client. In dev the real message passes through so the failure is readable in the network tab, and the server console prints a hint naming the action.
Because of the production mask, a plain thrown Error is the wrong way to reject bad input or denied access on purpose. Throw deny(status, message) instead; a deny's status, message, and data reach the client in every mode:
import { defineAction, deny } from 'hono-preact';
export const serverActions = {
publish: defineAction(
async (ctx, payload) => {
const user = await currentUser(ctx.c);
if (!user) throw deny(401, 'Sign in to publish.');
// ...
},
{ input: PublishSchema }
),
};
The same policy applies to route-bound chain resolution: when a route-bound action cannot resolve its page-use chain, the 500 message carries the resolver's detail only in dev.
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 { data } = moviesLoader.useData();
const { mutate, pending, error } = useAction(serverActions.addMovie, {
invalidate: 'auto',
onSuccess: (result) => console.log('added', result),
});
if (!data) return <p>Loading...</p>;
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, text/event-stream;q=0.9 and returns a discriminated result. The dual header lets json-returning actions negotiate a plain JSON response while still accepting a streaming one: when the action streams, the response arrives as text/event-stream and is delivered chunk by chunk to onChunk instead. The action body runs identically whether called from <Form> or useAction. useAction also accepts an opt-in schema option for client-side pre-validation; see Validation.
Options#
| Option | Type | Description |
|---|---|---|
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) => unknown | Called before the request fires on a dispatched mutation. Return value is passed to onError as snapshot for optimistic rollback. A client schema rejection short-circuits before dispatch, so it does not fire (see the note below the tables). |
onSuccess | (data) => void | Called with the action's return value on success. |
onError | (err, snapshot) => void | Called with the error and the onMutate snapshot when a dispatched mutation fails: a server deny, error, or timeout. A client schema rejection does not fire it (see the note below the tables). |
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<{ ok: true; data: Serialize<TResult> | undefined } | { 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. |
pending | boolean | true while the request is in flight. |
error | Error | null | The last error, or null if none. |
data | Serialize<TResult> | null | The 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.
onMutate and onError bracket a dispatched request. A server deny, error, or timeout rejects the in-flight mutation, so onError fires (with the onMutate snapshot for rollback). A client-side schema rejection is different: it short-circuits before any request, so neither onMutate nor onError fires. The failure is still surfaced every other way, as the returned { ok: false, error }, the hook's error state, and a deny(422) on useActionResult (decode the issues with getValidationIssues), so it renders identically to a server-caught validation failure. See Validation.
Cancellation#
mutate and <Form> submit both manage their in-flight request lifetime automatically.
Unmount aborts the request. If a component that called mutate unmounts while the request is in flight, the fetch is aborted. The abort is quiet: no error state is written, no onError fires. This prevents "setState after unmount" warnings without any cleanup code in the component.
<Form> submit works the same way: unmounting the form while a submit is in flight aborts the underlying fetch.
Opt-in caller signal. Pass { signal } as the second argument to mutate to wire in your own AbortSignal. Either source (the caller signal or the unmount signal) aborts the request when triggered:
const abortController = new AbortController();
const { mutate } = useAction(serverActions.addMovie);
// Start the mutation with a caller-owned signal
mutate({ title: 'Dune' }, { signal: abortController.signal });
// Cancel from anywhere in the component
abortController.abort();
Concurrent mutations are not auto-aborted. Calling mutate again while a previous call is in flight does not cancel the earlier request. Each call owns its own AbortController. This is deliberate: when a row of table cells each has an independent save button, aborting the first save on the second click would lose work. A component that wants abort-previous behavior brings its own signal:
const controllerRef = useRef<AbortController | null>(null);
const { mutate } = useAction(serverActions.saveRow);
const handleSave = (payload) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
mutate(payload, { signal: controller.signal });
};
No-JS form posts cannot be cancelled. The cancellation above applies to the JS-enhanced path only. When JavaScript is unavailable, <Form> submits a native HTML POST and there is no mechanism to abort it in flight.
Write-loss on navigation. When a component that owns a mutate call or a <Form> submit unmounts mid-flight (for example, the user navigates away), the request is aborted and the write may not reach the server. If a mutation must always complete regardless of navigation, drive it outside the cancelling component, or do not depend on it finishing after the component unmounts.
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 { data } = moviesLoader.useData();
const initialMovies = data ? data.movies.results : [];
const [items, setItems] = useState(initialMovies);
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 route-wide gating (every render, plus every route-bound loader and action, under a route), declare use on the route node in src/routes.ts. A route node's use covers route-bound units only: a bare defineAction or defineLoader is route-independent, so its RPC is not gated by the route node (see Middleware for why). To bind an action to that route node's gate by exact pattern, define it with route.action; otherwise gate the bare action with its own unit-level use.
Inference types#
The inference helpers extract an action ref's type parameters without re-importing the action definition. They resolve to the authored server-side type, not the serialized wire shape.
import type {
InferActionPayload,
InferActionResult,
InferActionChunk,
Serialize,
} from 'hono-preact';
import type { serverActions } from './movies.server.js';
type AddMovie = typeof serverActions.addMovie;
// The payload type declared in defineAction
type Payload = InferActionPayload<AddMovie>;
// { title: string }
// The result type declared in defineAction (server-side, pre-serialization)
type Result = InferActionResult<AddMovie>;
// { ok: boolean }
// The wire shape (JSON round-trip); compose with Serialize for the client-visible type
type WireResult = Serialize<InferActionResult<AddMovie>>;
// The streaming chunk type; never for non-streaming actions
type Chunk = InferActionChunk<AddMovie>;
Serialize<InferActionResult<typeof action>> is the correct type for the client-visible result. InferActionResult gives you the server-side type; Serialize<T> applies the JSON round-trip transformation (matching the type that useAction().data and <Form onSuccess> carry).
API reference#
| Helper | Resolves to |
|---|---|
InferActionPayload<A> | The action's payload type (TPayload) |
InferActionResult<A> | The action's result type (TResult, server-side) |
InferActionChunk<A> | The streaming chunk type (TChunk), or never for non-streaming actions |
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 ActionRef object that encodes the module and action name. Neither the function body nor its imports ever reach the browser bundle.
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 ActionRef with the module and action name. serverLoaderValidationPlugin enforces that .server.* files only export serverLoaders, serverActions, serverRooms, or serverSockets. 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.