> Source: https://framework.sbesh.com/docs # hono-preact docs A small, opinionated full-stack framework. Hono on the edge, Preact in the browser, manifest driven routes, typed RPC, streaming everywhere. ## Get started - [Quick start](/docs/quick-start): five-minute walkthrough from `pnpm install` to a deployed page. - [Project structure](/docs/structure): what each file in a hono-preact project does. ## Routing and layouts - [Routes](/docs/routes): `defineRoutes()` and the route table. - [Pages](/docs/pages): view components and `definePage()`. - [Layouts](/docs/layouts): nested layouts that survive navigation. - [Vite config](/docs/vite-config): the `honoPreact()` Vite plugin and its options. - [Active links](/docs/active-links): marking the current route in navigation. - [View transitions](/docs/view-transitions): animated route changes with the View Transition API. ## Data - [Loaders](/docs/loaders): server-rendered data with `defineLoader()`. - [Actions](/docs/actions): mutations via `
` and typed RPC. - [Optimistic UI](/docs/optimistic-ui): `useOptimistic()` and friends. - [Loading states](/docs/loading-states): fallbacks and suspense boundaries. - [Streaming](/docs/streaming): generator loaders, streaming forms, SSE. ## Auth and access - [Middleware](/docs/middleware): the unified `use` primitive for auth gates, redirects, request-scoped setup, and stream observers. - [CSRF protection](/docs/csrf): built-in CSRF token validation for forms and mutations. - [Composing Hono middleware](/docs/hono-middleware): wiring Hono-native middleware and custom routes alongside the framework. ## Operations - [Prefetch](/docs/prefetch): preloading routes on hover or focus. - [Link prefetch](/docs/link-prefetch): browser-level speculation rules for near-instant navigation. - [Reloading](/docs/reloading): invalidating loaders after mutations. - [`renderPage`](/docs/render-page): the SSR entry point. - [Deployment](/docs/deployment): shipping to Cloudflare Workers or Node.js. - [WebSockets](/docs/websockets): full-duplex connections via Hono's WebSocket helper. ## For LLMs and AI tools These docs are available as plain text for LLMs and AI coding assistants: /llms.txt is a curated index of every page, and /llms-full.txt is the entire documentation concatenated into one file. --- Looking for a working app to read? The whole **[demo](/demo)** is built with the framework. --- > Source: https://framework.sbesh.com/docs/quick-start # Quick Start Build a movies list with a server loader and a form action. One example covers the full route-table + view + server pattern. ## Prerequisites Scaffold a new app: ```bash pnpm create hono-preact my-app cd my-app ``` The scaffold runs `pnpm install` for you. Pass `--adapter=node` if you're targeting Node.js instead of Cloudflare Workers; see [Build & Deploy](./deployment) for the deployment side. ## Configure Vite `honoPreact()` requires an `adapter` option; without one it throws a clear "adapter required" error at startup. A minimal `vite.config.ts` looks like: ```ts import { honoPreact } from 'hono-preact/vite'; import { cloudflareAdapter } from 'hono-preact/adapter-cloudflare'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [honoPreact({ adapter: cloudflareAdapter() })], }); ``` The framework also ships a `nodeAdapter()` from `hono-preact/adapter-node` for non-Cloudflare deployments; see [Build & Deploy](./deployment) for the full picture. See [Vite Config](/docs/vite-config) for all `honoPreact()` options. Now start the dev server: ```bash pnpm dev ``` Open `http://localhost:5173`. The dev server runs both the Hono server and the Vite HMR client. The generated `server.tsx` is a one-line call to the framework's `createServerEntry`, which builds the `/__loaders` RPC, the page-action POST handler, the realtime socket upgrade, and the catch-all SSR handler on a single Hono app; `routes.ts` declares every URL in the app and which view (and optional `.server.ts` module) lives at each path. ## 1. Create a view Create `src/pages/movies.tsx` as a pure component with a default export: ```tsx import type { FunctionComponent } from 'preact'; const Movies: FunctionComponent = () => { return (

Movies

); }; Movies.displayName = 'Movies'; export default Movies; ``` Add it to `src/routes.ts`: ```ts import { defineRoutes } from 'hono-preact'; export default defineRoutes([ // ... existing routes { path: '/movies', view: () => import('./pages/movies.js') }, ]); ``` Open `http://localhost:5173/movies`. You should see "Movies". > The `view` field is a deferred dynamic import. The framework wraps it with preact-iso's `lazy` for code-splitting. No manual `` JSX is needed; the framework-generated client entry (`virtual:hono-preact/client`) registers everything from the manifest. ## 2. Add a server loader Create `src/pages/movies.server.ts`. Loaders are exported in a `serverLoaders` container so the view never needs to import `defineLoader` itself: ```ts import { defineLoader } from 'hono-preact'; export type Movie = { id: string; title: string }; const store: Movie[] = [ { id: '1', title: 'The Godfather' }, { id: '2', title: 'Chinatown' }, ]; const getMovies = () => store; export const serverLoaders = { default: defineLoader(async () => ({ movies: getMovies(), })), }; ``` Update `src/pages/movies.tsx` to consume the loader data via `.View()`, and pass the resulting component to `definePage`: ```tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movies.server.js'; const dataLoader = serverLoaders.default; const MoviesView = dataLoader.View(({ data }) => (

Movies

    {data.movies.map((m) => (
  • {m.title}
  • ))}
)); MoviesView.displayName = 'Movies'; export default definePage(MoviesView); ``` Add the `server` field to the route entry in `src/routes.ts` so `server.tsx` finds the module: ```ts { path: '/movies', view: () => import('./pages/movies.js'), server: () => import('./pages/movies.server.js'), } ``` Reload `http://localhost:5173/movies`. The list renders server-side on first load, with the data preloaded into the HTML. On client-side navigation away and back, the framework calls the loader over RPC. Same function, no manual wiring. `defineLoader(fn)` returns a typed `LoaderRef`. The Vite `moduleKeyPlugin` rewrites the call at build time to inject `{ __moduleKey, __loaderName }` so the key drives RPC routing (`/__loaders`), the `__id` Symbol identity, and HMR. `.View(render, opts)` returns a Preact component pre-wrapped in the loader's Suspense boundary, error context, and data context. `loader.useData()` inside the render function is argument-free and infers the return type from the loader function. ## 3. Add a server action Add `serverActions` to `src/pages/movies.server.ts`: ```ts import { defineAction, defineLoader } from 'hono-preact'; export type Movie = { id: string; title: string }; const store: Movie[] = [ { id: '1', title: 'The Godfather' }, { id: '2', title: 'Chinatown' }, ]; const getMovies = () => store; const addMovieToStore = (title: string) => store.push({ id: String(store.length + 1), title }); export const serverLoaders = { default: defineLoader(async () => ({ movies: getMovies(), })), }; export const serverActions = { addMovie: defineAction<{ title: string }, { ok: boolean }>( async (_ctx, { title }) => { addMovieToStore(title); return { ok: true }; } ), }; ``` Update `src/pages/movies.tsx` to add the form. Import `serverActions` alongside `serverLoaders`: ```tsx import type { FunctionComponent } from 'preact'; import { definePage, Form } from 'hono-preact'; import { serverLoaders, serverActions } from './movies.server.js'; const dataLoader = serverLoaders.default; const AddMovieForm: FunctionComponent = () => ( ); const MoviesView = dataLoader.View(({ data }) => (

Movies

    {data.movies.map((m) => (
  • {m.title}
  • ))}
)); MoviesView.displayName = 'Movies'; export default definePage(MoviesView); ``` The route entry in `routes.ts` doesn't change; actions ride along with the same `server` import. Submit a title in the form. The action runs on the server and the list updates. To automatically re-fetch the loader after the action, pass `invalidate: 'auto'` via `useAction` (see [Server Actions](/docs/actions) for the programmatic form). ## What's next - [The Route Table](/docs/routes): the full `defineRoutes` reference. - [Layouts & Nested Routes](/docs/layouts): share chrome across URLs without remounting. - [Server Loaders](/docs/loaders): caching, cross-page invalidation, path params. - [Server Actions](/docs/actions): `useAction`, optimistic updates, file uploads, streaming. - [Middleware](/docs/middleware): protect pages with the unified `use` array. - [Loading States](/docs/loading-states): show a fallback while the loader fetches. - [Build & Deploy](/docs/deployment): production build and deployment. --- > Source: https://framework.sbesh.com/docs/routes # The Route Table Declare every URL in your app in one place: `src/routes.ts`. A code-defined route table makes URL redesign a one-edit refactor and keeps URL structure independent of file layout, with no filesystem-based discovery to work around. ## Why one file A code-defined route table makes URL design a one-edit refactor (rename a path, move a leaf), keeps URL structure independent of file layout (organize files by domain, not by URL), and gives agents and devtools a single source of truth they can grep. The trade you make is a manual entry per route. ## A complete example ```ts // src/routes.ts import { defineRoutes } from 'hono-preact'; export default defineRoutes([ { path: '/', view: () => import('./pages/home.js') }, { path: '/demo/login', view: () => import('./pages/demo-login.js') }, { path: '/demo/projects', layout: () => import('./pages/projects-layout.js'), children: [ { path: '', view: () => import('./pages/projects-list.js'), server: () => import('./pages/projects-list.server.js'), }, { path: ':projectId/issues/:issueId', view: () => import('./pages/issue.js'), server: () => import('./pages/issue.server.js'), }, ], }, { path: '/demo/projects/:projectId', view: () => import('./pages/project.js'), server: () => import('./pages/project.server.js'), }, { path: '*', view: () => import('./pages/not-found.js') }, ]); ``` That table covers four URL behaviours: | Path | Behaviour | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `/`, `/demo/login`, `/demo/projects/:projectId` | Plain leaves. The `view` resolves to the page component. `server` (when present) is the sibling `.server.ts` module. | | `/demo/projects` | An empty-path child of a layout group. Renders `` wrapping the list view. | | `/demo/projects/:projectId/issues/:issueId` | A nested child of the same layout group. The same `ProjectsLayout` instance stays mounted across the navigation. | | `*` | Catch-all. Matches anything not matched above. | ## Route entry fields | Field | Type | Required | What it is | | ---------- | -------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `path` | `string` | always | URL pattern. Top-level paths start with `/`. Child paths must NOT start with `/` (they're relative to the parent). The wildcard `*` matches anything not matched by siblings. | | `view` | `() => Promise<{ default: ComponentType }>` | for leaves | The page component, behind a dynamic import. The framework wraps it with preact-iso's `lazy` for code-splitting. | | `layout` | `() => Promise<{ default: ComponentType }>` | for layout groups | A wrapper component that receives `children`. See [Layouts & Nested Routes](/docs/layouts). | | `server` | `() => Promise` | optional, leaves only | The sibling `.server.ts` module behind a dynamic import. Carries `serverLoaders` and `serverActions`. | | `use` | `Middleware[]` | optional | An array of page-layer middleware run for this node and all its descendants. See [Middleware](/docs/middleware#the-three-layers). | | `children` | `RouteDef[]` | for layouts and path groups | Nested routes. | ## The three valid shapes | Shape | Fields | Meaning | | ---------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | **Leaf** | `path`, `view`, optional `server` | A page. Cannot have `children`. | | **Layout group** | `path`, `layout`, `children` (required), optional `server` | A wrapper that renders matched descendants. May declare its own `server` for layout-scoped loaders. | | **Path group** | `path`, `children` (no `view`, no `layout`) | Pure URL prefix sharing. Useful for `/admin/*` without shared chrome. | `defineRoutes` validates the tree at runtime and throws with the offending URL if the shape is wrong: `view` + `layout`, `view` + `children`, `layout` without `children`, child path starting with `/`, or a route declaring none of `view`/`layout`/`children`. ## Mounting You do not write `iso.tsx` or `server.tsx`. The framework generates both as virtual modules from your `routes.ts` and `Layout.tsx`. The client entry hydrates `` inside ``; the server entry calls `createServerEntry`, which builds the loaders RPC, the page-action POST handler, the realtime socket upgrade, your optional `api.ts`, and the SSR catch-all on one Hono app and exports it as the worker's default. See [Project Structure](/docs/structure) for the file layout the plugin assumes. If you need to customize the client or server entry (rare), pass a path override via the Vite plugin's `clientEntry` / `entry` options and follow the patterns in the generated virtual modules. ## Inline imports stay inline Every `view`, `layout`, and `server` is `() => import('./path')`. The arrow shape is required for code-splitting: bundlers create a separate chunk per `import()` call site. A helper that takes a string (`view: lazy('./pages/home')`) would either lose splitting or require a transform plugin. The five characters of `() => ` per route is the trade for zero magic and full bundler support. ## Sharing references for non-layout routes When two leaves point at the same component (e.g. mounting one page at multiple paths), hoist the import thunk to a const so the framework's `lazy()` memoization sees the same identity and produces one shared component reference: ```ts const sharedView = () => import('./pages/shared.js'); defineRoutes([ // ... { path: '/a', view: sharedView }, { path: '/b', view: sharedView }, ]); ``` For layout groups, identity sharing is automatic: a layout group is registered at both `/path` and `/path/*` with the same component reference, so intra-group navigation does not remount the layout. See [Layouts & Nested Routes](/docs/layouts) for the full pattern. ## What `defineRoutes` returns ```ts type RoutesManifest = { tree: ReadonlyArray; // the original input, for introspection flat: ReadonlyArray; // the registered routes serverImports: ReadonlyArray< // every `server` thunk in the tree () => Promise >; serverRoutes: ReadonlyArray; // server-bound nodes (loaders/actions/rooms/sockets) routeUse: ReadonlyArray<{ path: string; use: ReadonlyArray; }>; // page-layer `use` chain per node }; ``` `tree` is the original input retained for devtools and dev-time introspection. `flat` is what `` registers with preact-iso. `serverImports` is what the generated server entry (through `createServerEntry`) adapts into the loader and action handler map. `serverRoutes` and `routeUse` are the introspection surfaces the page-action resolver and the page-layer `use` resolver read, so the render gate and the data gate cannot drift. ## Page-layer middleware on a route node A route entry can carry a `use` field to attach page-layer middleware to that node and all its descendants. The guard applies to the node's own view render (SSR and client navigation) and to every loader and action RPC under that subtree, so the render gate and the data gate cannot drift. ```ts import { defineRoutes } from 'hono-preact'; import { requireSession } from './auth/session.js'; export default defineRoutes([ { path: '/login', view: () => import('./pages/login.js') }, // ungated { path: '/dashboard', use: [requireSession], // gates everything below children: [ { path: '', view: () => import('./pages/dashboard.js'), server: () => import('./pages/dashboard.server.js'), }, { path: 'settings', view: () => import('./pages/settings.js'), server: () => import('./pages/settings.server.js'), }, ], }, ]); ``` `use` takes an array of middleware. When multiple ancestors each carry `use`, they compose outer-to-inner in tree order (outermost ancestor first). A sibling that is not a descendant of the guarded node is not affected. For the full chain model and middleware authoring, see [Middleware](/docs/middleware). ## See also - [Layouts & Nested Routes](/docs/layouts): the `layout` field, `LayoutProps`, identity preservation, the inner-router lowering. - [Adding Pages](/docs/pages): the page-authoring side: views, server bindings, definePage. - [Server Loaders](/docs/loaders): what the `server` import contains. - [Middleware](/docs/middleware): the full middleware chain and `use` authoring. - [Project Structure](/docs/structure): where each piece lives in the demo app. --- > Source: https://framework.sbesh.com/docs/layouts # Layouts & Nested Routes A layout is a wrapper component that stays mounted across navigations between sibling routes. Use a layout when several URLs share chrome: a sidebar, a header, a tab bar, a sub-navigation. The framework preserves the layout's identity across intra-group navigation so its state survives. ## Declaring a layout In `routes.ts`, a layout group is a route entry with `layout` and `children`: ```ts import { defineRoutes } from 'hono-preact'; export default defineRoutes([ { path: '/movies', layout: () => import('./pages/movies-layout.js'), children: [ { path: '', view: () => import('./pages/movies-list.js'), server: () => import('./pages/movies-list.server.js'), }, { path: ':id', view: () => import('./pages/movie.js'), server: () => import('./pages/movie.server.js'), }, ], }, ]); ``` URLs: - `/movies` matches the parent + the empty-path child. Renders ``. - `/movies/123` matches the parent + the `:id` child. Renders `` with `pathParams.id === '123'`. Navigating from `/movies` to `/movies/123` does NOT remount `MoviesLayout`. State held in the layout (an open menu, scroll position, a focused element) survives. ## The layout component A layout is a Preact component whose props match `LayoutProps` from `hono-preact`: ```tsx // src/pages/movies-layout.tsx import type { LayoutProps } from 'hono-preact'; export default function MoviesLayout({ children }: LayoutProps) { return (
home projects
{children}
); } ``` `children` is the matched descendant tree. The framework injects it. ## Typed route params `pathParams` is `Record` by default, so reading `route.pathParams.id` is unchecked. Register your route tree once and `useParams` returns params typed from the route's own pattern, with the route id validated against your real routes. Register in `routes.ts`, next to `defineRoutes`. Give the tree its own `as const` binding and register against `typeof routeTree`: ```ts import { defineRoutes, type RoutePaths } from 'hono-preact'; const routeTree = [ { path: '/movies', layout: () => import('./pages/movies-layout.js'), children: [{ path: ':id', view: () => import('./pages/movie.js') }], }, ] as const; export default defineRoutes(routeTree); declare module 'hono-preact' { interface RegisteredRoutes { paths: RoutePaths; } } ``` Then name the route you are on; the params are inferred from its pattern: ```tsx import { useParams } from 'hono-preact'; export default function Movie() { // id is typed `string`; an unknown or misspelled route id is a type error. const { id } = useParams('/movies/:id'); return

{id}

; } ``` Register against `typeof routeTree` (the tree array), not `typeof routes` (the manifest `defineRoutes` returns). The manifest form creates a type cycle. Before you register, `useParams` still works: it accepts any string and projects that pattern's params. ## URL composition rules | Rule | Why | | -------------------------------------------------------- | --------------------------------------------------------------------------- | | Top-level paths start with `/` | `/movies`, `/admin`, etc. Standard. | | Child paths must NOT start with `/` | A child path is appended to its parent (`/movies` + `:id` → `/movies/:id`). | | Empty child path (`''`) matches the parent's URL exactly | The "index" child of a layout group. | | Routes match in source order | First match wins, including catchalls like `*`. | | `*` matches anything not matched by siblings | Works at any nesting level. | | Trailing slashes normalised | `/movies/` and `/movies` are the same route. | ## Loaders per layout, per leaf A layout MAY declare its own `server` module. Its loaders are scoped to the layout's matched location, not the deepest active child route, and do not re-fire when navigating between children under the same layout. See [Layout-level loaders](/docs/loaders#layout-level-loaders) for the full pattern. ```ts { path: '/movies', layout: () => import('./pages/movies-layout.js'), server: () => import('./pages/movies-layout.server.js'), // ok: layout-scoped loaders children: [...], } ``` If you want chrome plus a leaf loader at the same URL, structure it as a layout with an empty-path child carrying the leaf data: ```ts { path: '/movies', layout: () => import('./pages/movies-layout.js'), children: [ { path: '', view: () => import('./pages/movies-list.js'), server: () => import('./pages/movies-list.server.js'), // loader for /movies }, // ... other children ], } ``` The list view consumes its loader data via `loader.useData()`; the layout never sees it. If a child needs data the layout ALSO needs to display, lift the data into the layout's render via context or pass it through as a prop in the empty-path child. ## Path-grouping (no layout) If you want to share a URL prefix without a wrapper, omit `layout` and `view`: ```ts { path: '/admin', children: [ { path: 'users', view: () => import('./pages/admin-users.js') }, { path: 'posts', view: () => import('./pages/admin-posts.js') }, ], } ``` Both `/admin/users` and `/admin/posts` are independent routes; they share only the URL prefix. No shared chrome means no shared mount, so navigating between them remounts each view. ## Nested layouts Layouts can contain layouts. Each layer of nesting adds another wrapper around the matched leaf: ```ts { path: '/dashboard', layout: () => import('./pages/dashboard-layout.js'), children: [ { path: '', view: () => import('./pages/dashboard-home.js') }, { path: 'reports', layout: () => import('./pages/reports-layout.js'), children: [ { path: '', view: () => import('./pages/reports-index.js') }, { path: ':id', view: () => import('./pages/report.js') }, ], }, ], } ``` Renders for `/dashboard/reports/42`: ``` ``` Navigating `/dashboard/reports → /dashboard/reports/42` remounts only ``. Both layouts stay mounted. Navigating `/dashboard/reports/42 → /dashboard` remounts everything below `` (because `` is no longer matched). ## How the framework preserves identity preact-iso's `` decides whether a navigation is "the same component re-rendered" or "a different component mounted" by comparing component references between the matched outgoing and incoming routes. If the references are the same, the component re-renders in place; if different, it unmounts and a new one mounts. For a layout group, the framework registers ONE shared component at the outer router under both `/path` and `/path/*`. That shared component renders `{...children}`. The inner `` matches the rest of the URL against the layout's children. Outer navigation between `/movies` and `/movies/123` finds the SAME outer component, so the layout re-renders in place; the inner Router resolves the new child and swaps its content. Nesting works recursively: a layout-group child of a layout group registers as a single component within its parent's inner Router, and so on. ## What's out of scope | Feature | Workaround | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Pathless layout routes (no URL segment) | Repeat the wrapper as a regular component imported by each view that needs it. | | `useParentLoaderData` / loader composition | Layout loaders and leaf loaders both run, but they do not see each other's data. Read from a context provided by the layout, or pass via props. | | Path-prefix middleware | Use Hono `.use()` in your `api.ts` for path-prefix middleware. | Layout-level loaders DO run in parallel with leaf loaders (see [Layout-level loaders](/docs/loaders#layout-level-loaders)); what's out of scope is letting a leaf loader read its parent layout loader's data through framework plumbing. ## Sharing chrome without a layout If you want chrome shared across unrelated URLs (not a contiguous URL prefix), don't declare a layout group. Instead, write the chrome as a regular component and import it directly into each view: ```tsx // src/components/MarketingChrome.tsx export function MarketingChrome({ children }: { children: ComponentChildren }) { return (
{children}
); } // src/pages/about.tsx import { MarketingChrome } from '../components/MarketingChrome.js'; export default function About() { return (

About

); } ``` This pattern remounts the chrome on each navigation, so it's only appropriate when the chrome has no state that needs to survive nav. For shared chrome with state, prefer a layout group with an empty-path index child. ## See also - [The Route Table](/docs/routes): the `defineRoutes` factory and the manifest. - [Adding Pages](/docs/pages): view authoring. - [Server Loaders](/docs/loaders): what `server` imports provide. --- > Source: https://framework.sbesh.com/docs/pages # Adding Pages Add a page by creating a view component and registering it in the route table. The framework handles code-splitting and server wiring automatically; the route table is the single source of truth for every URL in the app. ## Standard pages A view component is a default-exported Preact component. The page itself imports only what it consumes. The route table maps a URL to the view (and optionally its server module); `definePage` wraps the component with a `Wrapper` or `errorFallback` when needed, and page-layer middleware attaches as `use` on the route node in `routes.ts`. ### Step 1: Create `src/pages/about.tsx` ```tsx import type { FunctionComponent } from 'preact'; const About: FunctionComponent = () => { return
About this app.
; }; About.displayName = 'About'; export default About; ``` The page is a default-exported component. No `` wrapper, no `RouteHook` plumbing; those concerns belong to `definePage` (only when the page needs a `Wrapper` or an `errorFallback`). ### Step 2: Add to `src/routes.ts` ```ts import { defineRoutes } from 'hono-preact'; export default defineRoutes([ // ... other routes { path: '/about', view: () => import('./pages/about.js') }, ]); ``` `view` is a deferred dynamic import. The framework wraps it with preact-iso's `lazy` for code-splitting. For pages that need data, import `serverLoaders` from the sibling `.server.ts` and use `.View()` to create the component: ```tsx // src/pages/about.tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './about.server.js'; const AboutView = serverLoaders.default.View(({ data }) => (
{/* ... */}
)); export default definePage(AboutView); ``` ```ts // src/routes.ts { path: '/about', view: () => import('./pages/about.js'), server: () => import('./pages/about.server.js'), } ``` The `server` field declares the sibling `.server.ts` exists; the generated `server.tsx` (via `createServerEntry`) wires it into the loader and action handlers automatically. ### Step 3: Link to it From anywhere in the app: ```tsx About ``` preact-iso intercepts clicks on same-origin `` tags and handles them as client-side navigations. ## Pages with shared chrome When several routes share a header, sidebar, or other wrapper, declare a layout group instead of repeating the chrome in every view. See [Layouts & Nested Routes](/docs/layouts) for the full pattern. ```ts { path: '/movies', layout: () => import('./pages/movies-layout.js'), children: [ { path: '', view: () => import('./pages/movies-list.js'), server: () => import('./pages/movies-list.server.js') }, { path: ':id', view: () => import('./pages/movie.js'), server: () => import('./pages/movie.server.js') }, ], } ``` ## MDX content pages MDX is supported via the `@mdx-js/rollup` Vite plugin (configured in `vite.config.ts`). To mount a whole folder of MDX as routes, pass `import.meta.glob` to `contentRoutes`, which turns each file into a framework route node. Spread those nodes under a layout group so the pages share chrome: ```ts import { defineRoutes, contentRoutes } from 'hono-preact'; import { MdxArticle } from './components/MdxArticle.js'; export default defineRoutes([ // ... { path: '/docs', layout: () => import('./components/DocsLayout.js'), children: [ ...contentRoutes(import.meta.glob('./pages/docs/**/*.mdx'), { wrapper: MdxArticle, }), { path: '*', view: () => import('./components/DocsNotFound.js') }, ], }, ]); ``` `import.meta.glob` must be written inline with a literal pattern (a Vite requirement); `contentRoutes` receives the resulting module map. Each MDX file becomes its own route: server-rendered, navigable, and code-split. The `*` child renders a docs-styled "not found" inside the layout for unmatched `/docs/...` URLs. ### The wrapper `contentRoutes` wraps every page in a single-element root, here `MdxArticle` (an `
`). The wrapper is required: MDX compiles to a multiple-sibling Fragment root, which does not hydrate stably on its own; the single wrapping element is what makes hydration correct. It is also the natural home for prose styling. The default wrapper, if you pass none, is a bare `
`. ### `contentRoutes(modules, options?)` | Param | Type | Description | | ----------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `modules` | `Record Promise>` | The map from `import.meta.glob`. Keys are file paths; each value is a lazy importer whose `default` export is the page component. | | `options.wrapper` | `ComponentType<{ children }>` | Single-root wrapper around each page. Defaults to a bare `
`. | | `options.slug` | `(key: string) => string` | Map a glob key to its route `path`, overriding the default rule. | | `options.base` | `string` | Prefix stripped from each key before slug derivation. Defaults to the longest common directory of all keys. | The default slug rule strips the common directory prefix and the file extension and collapses a trailing `index` to the empty path: `index.mdx` serves the directory root (`/docs`), `quick-start.mdx` serves `/docs/quick-start`, and `components/dialog.mdx` serves `/docs/components/dialog`. **To add a new MDX page**, drop a file into `src/pages/docs/.mdx`; `contentRoutes` picks it up via the glob, with no `routes.ts` edit. (See `.claude/skills/add-docs-page.md` for the project-local skill.) ## View transitions Route changes trigger a view transition automatically in browsers that support `document.startViewTransition`. See [View Transitions](/docs/view-transitions) for the full toolkit (named elements, lifecycle hooks, direction-driven types, and persistent elements). ## See also - [The Route Table](/docs/routes): full reference for `defineRoutes`. - [Layouts & Nested Routes](/docs/layouts): when and how to share chrome across URLs. - [Server Loaders](/docs/loaders): what goes in the `.server.ts` file. - [Middleware](/docs/middleware): the `use` field on a route node in `routes.ts` for auth gates and per-page middleware. - [Active Links](/docs/active-links): linking between pages with active-state highlighting. --- > Source: https://framework.sbesh.com/docs/active-links # Active Links Highlighting the current page in navigation needs one question answered: does the current URL match a given route? `useRouteActive`, `useRouteMatch`, and `` answer it against the same route grammar your route table uses, so `/posts/:id` matches any post and hands you the `id`. ## `useRouteActive` Returns `true` when the current path matches the route. ```tsx import { useRouteActive } from 'hono-preact'; function Tab() { const active = useRouteActive('/docs'); return ( Docs ); } ``` By default the match is **exact**. Pass `{ exact: false }` to also match descendant paths, e.g. highlight a section for every page beneath it: ```tsx // active on /docs/components AND /docs/components/dialog const inSection = useRouteActive('/docs/components', { exact: false }); ``` One caveat: because every path is a descendant of `/`, a non-exact match on the root (`useRouteActive('/', { exact: false })`) is active everywhere. Keep a "Home" link `exact` (the default). ## `useRouteMatch` Same matching, but returns the captured params (or `null`). Use it when you want to both test _and_ read the dynamic segments. ```tsx import { useRouteMatch } from 'hono-preact'; function Crumb() { const params = useRouteMatch('/posts/:id'); // on /posts/42 -> { id: '42' }, elsewhere -> null return params ? Post {params.id} : null; } ``` The route accepts the full pattern grammar: `:param`, `*`, `+`, `:param?`. ## `buildPath` Build a typed URL from a route pattern and its params. TypeScript enforces that the pattern is registered and that the params object matches the pattern's dynamic segments. Param values must be strings; convert numbers or other types before passing them. ```tsx import { buildPath } from 'hono-preact'; // /posts/42 const href = buildPath('/posts/:id', { id: '42' }); // /dashboard (no params needed; second argument is omitted) const dashHref = buildPath('/dashboard'); ``` `buildPath` is the safe alternative to template literals: a wrong key or a missing param is a compile error, not a silent bug. ## `` A `` that applies an active or inactive class for you (and sets `aria-current="page"` when active). ```tsx import { NavLink } from 'hono-preact'; Docs ; ``` Any `class` you pass is always applied; `activeClass` / `inactiveClass` are merged on top per state. Other anchor props (`target`, `rel`, `data-*`) pass straight through. Use `exact={false}` for section links, and `match` when the link target differs from the pattern you want to highlight on: ```tsx // links to /posts, but stays active on /posts/123 Posts ``` ## Navigating programmatically `useNavigate()` returns a `navigate(path, options?)` function for navigating from an event handler (a logout button, a post-action redirect). A soft navigate runs the same client middleware, loaders, and view transitions as a `` click. ```tsx import { useNavigate } from 'hono-preact'; function LogoutButton() { const navigate = useNavigate(); return ( ); } ``` | Option | Type | Default | Description | | --------- | --------- | ------- | ----------------------------------------------------------------------- | | `replace` | `boolean` | `false` | Replace the current history entry instead of pushing a new one. | | `reload` | `boolean` | `false` | Do a full-page navigation (clean slate) instead of a client navigation. | ## API reference `` forwards any other `` attribute (e.g. `target`, `rel`, `data-*`) to the underlying anchor and sets `aria-current="page"` while the link is active. | Prop | Type | Default | Description | | --------------- | --------- | -------- | --------------------------------------------------------------- | | `href` | `string` | required | Destination path. | | `match` | `string` | `href` | Pattern tested for the active state. | | `exact` | `boolean` | `true` | Match the full path; set `false` for prefix (section) matching. | | `class` | `string` | none | Always applied. | | `activeClass` | `string` | none | Merged in when the link is active. | | `inactiveClass` | `string` | none | Merged in when the link is not active. | ## See also - [The Route Table](/docs/routes): the path grammar `match` tests against. - [Link Prefetch](/docs/link-prefetch): prefetching the page a link points to. --- > Source: https://framework.sbesh.com/docs/loaders # Server Loaders Pages often need data. On the server, that data should come from a direct function call. In the browser during navigation, it goes through an RPC call to the server. Writing this branch manually is error-prone, so the loader system handles it automatically. If your loader produces values over time (dashboards, log tails, live feeds), see [Streaming](/docs/streaming). ## Example: listing page Snippets on this page assume: ```ts type MovieList = { results: { id: number; title: string }[] }; type Movie = { id: number; title: string }; ``` **`src/pages/movies.server.ts`** holds the server-only data fetching: ```ts import { getMovies } from '@/server/movies.js'; import { defineLoader } from 'hono-preact'; export const serverLoaders = { default: defineLoader(async () => { const movies = await getMovies(); // direct call; never runs in the browser return { movies }; }), }; ``` **`src/pages/movies.tsx`** creates a self-contained view component via `.View()`, then passes it to `definePage`: ```tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movies.server.js'; const dataLoader = serverLoaders.default; const MoviesView = dataLoader.View(({ data }) => (
    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
)); export default definePage(MoviesView); ``` **`src/routes.ts`** wires the URL to the view and its server module: ```ts import { defineRoutes } from 'hono-preact'; export default defineRoutes([ // ... { path: '/movies', view: () => import('./pages/movies.js'), server: () => import('./pages/movies.server.js'), }, ]); ``` `defineLoader(fn)` returns a typed `LoaderRef`. The Vite `moduleKeyPlugin` rewrites the call at build time to inject `{ __moduleKey, __loaderName }` so the RPC handler can dispatch to the correct function. `definePage(Component)` returns a routable component that self-wraps in `` with any page-level bindings captured in a closure. ## Example: detail page (using route params) The loader function receives `{ location }` which carries `location.pathParams`. Use this to load a record by ID. The function's return type is inferred and propagated through `defineLoader(fn)` to all consumption points. ```ts // src/pages/movie.server.ts import { getMovie } from '@/server/movies.js'; import { defineLoader } from 'hono-preact'; export const serverLoaders = { default: defineLoader(async ({ location }) => { const movie = await getMovie(location.pathParams.id); return { movie }; }), }; ``` ### Typed loader params After the one-time [route registration](/docs/layouts#typed-route-params), bind a server module to its route with `serverRoute`. `route.loader(fn)` types `ctx.location.pathParams` from the route's pattern (no annotation needed), and the route id autocompletes against your registered routes: ```ts import { serverRoute } from 'hono-preact'; const route = serverRoute('/movies/:id'); export const serverLoaders = { // location.pathParams.id is typed `string` default: route.loader(async ({ location }) => { const movie = await getMovie(location.pathParams.id); return { movie }; }), }; ``` `serverRoute` names the route once for the whole module, the common one-module-one-route case. For a route-agnostic or shared-across-routes loader, use `defineLoader` directly: `defineLoader(routeId, fn)` types params the same way, while `defineLoader(fn)` leaves `pathParams` as `Record`. ```ts // lower-level: route id per loader, or omit it for untyped params default: defineLoader('/movies/:id', async ({ location }) => { const movie = await getMovie(location.pathParams.id); return { movie }; }), ``` ### The loader context Every loader is called with a single context argument with the same three fields, regardless of whether it returns a value or streams: ```ts type LoaderCtx = { c: Context; // typed Hono Context, always available location: RouteHook; // route-change location info signal: AbortSignal; // aborts when the user navigates away }; ``` | Field | Description | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `c` | The request's Hono `Context`. Use it for headers, cookies, KV/D1/env `Bindings`, the request URL, etc. With typed `Bindings`, narrow inside the function body. | | `location` | Current route navigation info (the same shape as `useLocation()`). Carries `pathParams`, `searchParams`, the resolved path, and the route's metadata. | | `signal` | An `AbortSignal` that aborts when the user navigates away mid-load. Forward it to `fetch` and any cancellable server work to short-circuit on navigation. | Destructure whichever fields a loader needs: ```ts defineLoader(async ({ c, location, signal }) => { const token = getCookie(c, 'session'); const res = await fetch(`/api/movies/${location.pathParams.id}`, { signal }); return res.json(); }); ``` `ctx.c` is the request's Hono `Context`. A server loader can read cookies (`getCookie(ctx.c, …)`), reach app `Bindings` (`ctx.c.env.MY_KV`), or set a response header before yielding. The shape is `Context`; users with typed `Bindings` can narrow inside the function body. By convention, loaders read; actions write. Setting cookies/headers from a loader is allowed but discouraged. On the SSR path, `setCookie(ctx.c, …)` from a non-streaming loader survives to the response. Streaming loaders can set cookies too, but only **before their first `yield`**: that part of the loader runs during the server render, while the response headers are still open. Anything written after the first `yield` runs while the stream is already in flight, with headers committed, and is dropped. When in doubt, rotate sessions from an action. ## The `serverLoaders` container Loaders are exported in a named container symmetric with `serverActions`. Every `.server.*` file uses this shape, whether it has one loader or many: ```ts // src/pages/movies.server.ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { default: defineLoader(async () => { const movies = await getMovies(); return { movies }; }), }; ``` A single-loader page conventionally uses the key `default`. Multi-loader pages give each loader a descriptive name. There is no special-cased single-loader export; the plugin treats all entries the same way. ## The `.View()` factory `.View(render, opts)` is the primary way to consume a loader. It returns a Preact component pre-wrapped in the loader's own Suspense boundary, error boundary, data context, and reload context: ```ts loader.View

( render: (args: P & { data: T; error: Error | null; reload: () => void }) => ComponentChildren, opts?: { fallback?: ComponentChildren; errorFallback?: ComponentChildren } ): FunctionComponent

``` The render function receives `{ data, error, reload }` plus any props declared by the generic `P`. Inside the render function's subtree, `loader.useData()` also works for descendants that need data without prop-drilling. **Basic usage:** ```tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movies.server.js'; const dataLoader = serverLoaders.default; const MoviesView = dataLoader.View( ({ data }) => (

    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
), { fallback:

Loading...

} ); export default definePage(MoviesView); ``` **Prop passthrough** (using the generic `P`): ```tsx const MovieCard = dataLoader.View<{ highlight: boolean }>( ({ data, highlight }) => (

{data.movie.title}

), { fallback: } ); // Use site: ; ``` **Error handling inside the render function:** The `error` argument is non-null after the stream's first chunk (for streaming loaders) or on a re-fetch error. Use `reload` to let the user retry: ```tsx const StatsView = statsLoader.View( ({ data, error, reload }) => ( <> {error && }

Count: {data.count}

), { fallback: } ); ``` ## Composing multiple loaders per route This is the supported pattern when a route needs more than one data source. Declare each source as its own loader in the same `serverLoaders` container; the framework wires each one independently. There is no separate "parallel fetch" or "loader group" API; composition is just adding another key. Each loader gets its own independent Suspense boundary, error boundary, cache key, and streaming section, so a slow or failing source never blocks the rest of the page: ```ts // src/pages/movie.server.ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { summary: defineLoader(async ({ location }) => getMovie(location.pathParams.id) ), cast: defineLoader(async function* ({ location }) { for await (const member of streamCast(location.pathParams.id)) yield member; }), similar: defineLoader( async ({ location }) => fetchSimilar(location.pathParams.id), { params: ['genre'] } ), }; ``` ```tsx // src/pages/movie.tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movie.server.js'; const { summary, cast, similar } = serverLoaders; const Summary = summary.View( ({ data, error, reload }) => error ? : , { fallback: } ); const Cast = cast.View(({ data }) => , { fallback: , }); const Similar = similar.View(({ data }) => , { fallback: , }); function MovieDetail() { return (
); } export default definePage(MovieDetail); ``` Each `View` component streams independently. `` can show content while `` and `` are still loading. ## The `.Boundary` escape hatch When you need to interleave loader-aware UI with non-loader content, use `loader.Boundary` directly and call `loader.useData()` inside it: ```tsx function Header() { return ( }> ); } function HeaderWithSummary() { const data = summary.useData(); return (

{data.title}

); } ``` `loader.useData()` must be called inside a matching `Boundary` (placed by `.View()` or explicitly via `loader.Boundary`). Calling it outside throws with a clear error message. ## Search-param dependencies (`params`) By default, a loader's cache key includes only the path and path params. Search params that the loader doesn't use (analytics tags, UI state, tracking tokens) don't cause refetches. Declare search-param dependencies per loader with the `params` option: ```ts export const serverLoaders = { // path-only cache key (default); never refetches on search-param changes summary: defineLoader(async ({ location }) => getMovie(location.pathParams.id) ), // refetches when ?genre changes, but not on ?modal or ?utm_source similar: defineLoader( async ({ location }) => fetchSimilar(location.pathParams.id, location.searchParams), { params: ['genre'] } ), // refetches on any search-param change; for search/filter pages results: defineLoader( async ({ location }) => searchMovies(location.searchParams), { params: '*' } ), }; ``` The RPC request always sends the full location to the server; `params` only narrows the client-side cache key. ## Layout-level loaders A loader declared in `layout.server.*` is automatically scoped to the layout's matched location, not the deepest child route. It does not re-fire when navigating between child routes under the same layout: ```ts // src/pages/movies/layout.server.ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { activity: defineLoader(async function* ({ signal }) { for await (const event of subscribeToActivity(signal)) { yield event; } }), }; ``` ```tsx // src/pages/movies/layout.tsx import { serverLoaders } from './layout.server.js'; const Feed = serverLoaders.activity.View( ({ data }) => , { fallback: null } ); export default function MoviesLayout({ children, }: { children: ComponentChildren; }) { return (
{children}
); } ``` Navigating `/movies` to `/movies/123` does not unmount the layout, does not change the layout's matched location, and therefore does not re-fire the `activity` loader. The streaming subscription continues across child route changes. ## Registering the loader endpoint You do not wire `loadersHandler` directly. The framework's Vite plugin generates the server entry, which mounts `loadersHandler` on `POST /__loaders`, a page POST handler for actions, your optional `api.ts`, and the SSR catch-all on one Hono app and exports it as the worker's default. `loadersHandler` routes by `moduleKey::loaderName` (the path-derived key injected by `moduleKeyPlugin` combined with the loader's name in the container); modules without `__moduleKey` are silently skipped at map-build time. If you ever need a custom server entry, see [`renderPage`](/docs/render-page) for the manual wiring contract. ## Caching navigation results Every loader gets its own cache automatically. Repeated navigations to the same page (with the same cache key) hit the cache and skip the RPC call. To clear the cache, call `loader.invalidate()`. The next navigation re-runs the loader. ```ts // clear the cache on the current loader dataLoader.invalidate(); ``` ### Sharing a cache between loaders If two loaders need to share storage (for example, a detail page that should populate the same cache as a list page), construct a `LoaderCache` explicitly and pass it via the `cache` option: ```ts import { createCache, defineLoader } from 'hono-preact'; const moviesCache = createCache<{ movies: MovieList }>(); export const serverLoaders = { default: defineLoader(serverLoader, { cache: moviesCache }), }; ``` `createCache()` takes no arguments. Loader caches are referenced by the loader, not by name. ### Cache registry scope The loader-cache registry that lets `loader.invalidate()` work across importers is keyed off a global `Symbol.for('@hono-preact/iso/loaderCaches')`. The map is therefore shared across every consumer of `@hono-preact/iso` running in the same JavaScript realm. On Cloudflare Workers (the framework's primary target) each request runs in a short-lived isolate, so this is effectively per-process and per-tenant. On a long-lived Node server hosting multiple tenants from one realm, the _registry_ is shared across tenants. The cache contents are NOT cross-leaking (each loader's cache is keyed by its own `__moduleKey` + a private symbol minted at `defineLoader` time), but multi-tenant operators should be aware that the framework assumes per-realm isolation. ## Page bindings with `definePage` Per-page bindings (`Wrapper`, `errorFallback`) live with the page component. `definePage` captures them: ```tsx import { definePage } from 'hono-preact'; export default definePage(Component, { Wrapper }); ``` Loader and fallback bindings live on `serverLoaders.name.View()` inside the page's JSX tree, not on `definePage`. A page with no loader simply skips `definePage` and exports the component directly: ```tsx // src/pages/home.tsx const Home = () =>
Welcome.
; export default Home; ``` A `Wrapper` binding wraps the page in a custom element while keeping the SSR plumbing: ```tsx // src/pages/movie.tsx import { definePage, type WrapperProps } from 'hono-preact'; import { serverLoaders } from './movie.server.js'; const { default: movieLoader } = serverLoaders; const MovieView = movieLoader.View( ({ data }) =>
{data.movie.title}
, { fallback: } ); function MovieWrapper(props: WrapperProps) { return
; } export default definePage(MovieView, { Wrapper: MovieWrapper }); ``` ## Cross-page invalidation When a mutation on one page should refresh data on another, import the other page's loader ref and pass it to `useAction({ invalidate: [...] })`. Invalidation is by reference, not by name: ```ts // movies.server.ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { default: defineLoader(async () => ({ movies: await getMovies() })), }; ``` ```tsx // reviews.tsx: a different page whose action should also refresh the movie list import { useAction } from 'hono-preact'; import { serverLoaders as moviesLoaders } from './movies.server.js'; import { serverActions } from './reviews.server.js'; const moviesLoader = moviesLoaders.default; const { mutate } = useAction(serverActions.addReview, { invalidate: [moviesLoader], // clears moviesLoader's cache on success }); ``` `invalidate` accepts an array of loader refs, so a single action can refresh multiple targets: `invalidate: [moviesLoader, ratingsLoader]`. Use `'auto'` instead of an array to invalidate the current page's loader only. ## How it works A page is a file pair: - `movies.server.ts`: the server loaders collected in a `serverLoaders` container. Never reaches the browser bundle. - `movies.tsx`: a pure component that consumes each loader's data via `.View()` (or `loader.useData()` inside a `loader.Boundary`) and is exported through `definePage(Component)`. `definePage(Component)` returns a routable component that self-wraps in `` with any page-level bindings. The route table in `routes.ts` declares which view and which `.server.ts` live at each URL via the `view` and `server` fields; the framework wires the rest. **At runtime:** 1. **SSR:** each loader in `serverLoaders` runs directly during `prerender`. Its return value is JSON-serialized and embedded in the page's HTML. 2. **Hydration (first load):** the client reads that embedded data. No fetch is fired. 3. **Client-side navigation:** the Vite plugin replaces the `serverLoaders` import with a Proxy stub. Accessing `serverLoaders.name` returns a `LoaderRef` whose RPC stub POSTs `{ module, loader, location }` to `POST /__loaders`. The server runs the real function and returns JSON. Because every value crosses this boundary as JSON, the client receives the _serialized_ shape of a loader's return, not the server-side type. The data hooks reflect that honestly: `loader.useData()` and the `.View()` render argument are typed `Serialize`, the JSON round-trip of the loader's return `T`. A `Date` field is therefore typed (and arrives) as a `string`; values JSON cannot carry (functions, `bigint`, symbols) are dropped, or surface as `never` so a non-serializable return is a compile error. The same `Serialize` applies to action results (`useAction().data`, `
`, `useActionResult()`). ## Timeouts Loaders get a deadline. By default every call has 30 seconds to finish; the deadline starts when the handler receives the request. Pass `timeoutMs` on `defineLoader` to override: ```ts export const slowReport = defineLoader( async () => { /* ... */ }, { timeoutMs: 60_000 } ); ``` Pass `timeoutMs: false` to opt out entirely (useful for long-running streams): ```ts export const longLivedStream = defineLoader( async function* () { /* yields indefinitely */ }, { timeoutMs: false } ); ``` When a deadline fires, the loader'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 framework default timeout is 30s. Override it per loader with `defineLoader({ timeoutMs })` (a number of milliseconds, or `false` to disable the timeout for that loader). ### Delaying the loading fallback On a fast connection a loader's `fallback` can flash on screen for a few milliseconds before the data lands, which reads as a flicker. The framework waits `fallbackDelay` milliseconds (default `100`) before mounting the fallback on a client navigation; if the response arrives first, the fallback never paints. Set it per loader: ```ts import { defineLoader } from 'hono-preact'; export const movie = defineLoader( async ({ location }) => fetchMovie(location.pathParams.id), { fallbackDelay: 200 } ); ``` Pass `fallbackDelay: 0` to show the fallback immediately. The delay applies in the browser only; server-rendered output is unchanged. Both `loader.Boundary` and `loader.View` inherit it. ## The server/client boundary Two Vite plugins enforce that `.server.*` code never reaches the browser. `serverOnlyPlugin` rewrites `*.server.*` imports in the client bundle: the `serverLoaders` named export becomes a Proxy whose `get(_, name)` returns a fresh `LoaderRef` stub for that name. Any imported `serverActions` becomes a Proxy of action stubs. `serverLoaderValidationPlugin` fails the build if a `.server.*` file has unrecognised named exports. ## Options Pass a second argument to `defineLoader` to configure a loader: | Option | Type | Default | Description | | --------------- | ----------------- | ------- | ---------------------------------------------------------------------------------------- | | `params` | `string[] \| '*'` | `[]` | Search params that change the cache key; `'*'` means any. | | `cache` | `LoaderCache` | auto | A shared cache; see Caching navigation results. | | `timeoutMs` | `number \| false` | `30000` | Per-loader deadline; `false` disables it. | | `fallbackDelay` | `number` | `100` | Delay (ms) before the loading fallback mounts on a client nav; `0` shows it immediately. | | `use` | `LoaderUse` | none | Per-loader middleware and stream observers. | Most options have a dedicated section above; this table is the at-a-glance summary. ## See also - [Loading States](/docs/loading-states): the loading and error UI for a loader's data. - [Reloading Data](/docs/reloading): invalidating and re-running loaders. - [Prefetching](/docs/prefetch): warming a loader before navigation. - [Server Actions](/docs/actions): mutations that can invalidate loaders. --- > Source: https://framework.sbesh.com/docs/loading-states # Loading States Show a loading UI during client-side navigation by passing a `fallback` to `loader.View()`, so users see a skeleton or spinner instead of a blank page while their data loads. ## Basic usage ```tsx // src/pages/movies.tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movies.server.js'; const moviesLoader = serverLoaders.default; const MoviesView = moviesLoader.View( ({ data }) => (
    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
), { fallback:

Loading…

} ); export default definePage(MoviesView); ``` ```ts // src/routes.ts { path: '/movies', view: () => import('./pages/movies.js'), server: () => import('./pages/movies.server.js'), } ``` `fallback` is rendered by the Suspense boundary `.View()` installs around the loader fetch. It only appears on client-side navigation; SSR and first-load hydration read from preloaded data and render immediately. ## When fallback shows | Navigation | Fallback shown? | | ---------------------------- | ---------------------------------------------- | | SSR (first load) | No. Data is preloaded into the HTML. | | Hydration | No. Client reads from `data-loader` attribute. | | Client-side nav (cache miss) | Yes, until the loader resolves. | | Client-side nav (cache hit) | No. Cached data renders immediately. | ## Using a skeleton Any Preact element works as a fallback, including a layout-matching skeleton: ```tsx const MoviesSkeleton = () => (
    {Array.from({ length: 5 }).map((_, i) => (
  • ))}
); const MoviesView = moviesLoader.View( ({ data }) => (
    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
), { fallback: } ); ``` ## Error fallback If the loader rejects, the boundary renders `errorFallback` instead of unwinding the page tree. Pass it alongside `fallback` in the same `.View()` options. The fallback may be an element or a function that receives the error and a `reset()` callback: ```tsx const MoviesView = moviesLoader.View( ({ data }) => (
    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
), { fallback:

Loading…

, errorFallback: (err, reset) => (

Couldn't load movies: {err.message}

), } ); ``` Calling `reset()` clears the boundary so the next render attempt re-enters the loader. For runtime invalidation of cached data from elsewhere on the page, use [`useReload`](/docs/reloading) or [cross-page invalidation](/docs/loaders#cross-page-invalidation). ## `.View()` options reference Both options are passed as the second argument to `loader.View(render, opts)`: | Option | Type | Description | | --------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | | `fallback` | `ComponentChildren` | Shown while the loader is pending. | | `errorFallback` | `ComponentChildren \| ((err, reset) => ComponentChildren)` | Shown when the loader errors; the function form receives `reset`. | ## Page-level fallbacks `definePage` accepts a page-level `errorFallback` that catches errors from the rest of the page tree (e.g. a render-time throw outside any loader boundary): ```tsx export default definePage(MoviesView, { errorFallback: (err) =>

Something went wrong: {err.message}

, }); ``` Loader-specific loading and error UI lives on `.View()` / `.Boundary`. The page-level `errorFallback` is the outer safety net. ## See also - [Server Loaders](/docs/loaders): where `.View()` comes from. - [Streaming](/docs/streaming): fallback behavior for streaming loaders. - [Middleware](/docs/middleware): the `use` binding. - [Project Structure](/docs/structure): the full `definePage` bindings list (including `Wrapper`). --- > Source: https://framework.sbesh.com/docs/reloading # Reloading Data Sometimes you need to re-run the loader imperatively, for example after a user adds a record to a table. The `useReload` hook lets you do this from within a page component. ## Basic usage Snippets on this page assume `type MovieList = { results: { id: number; title: string }[] }`. **`src/pages/movies.server.ts`** holds the loader (the cache is auto-attached): ```ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { default: defineLoader(async () => ({ movies: await getMovies(), })), }; ``` **`src/pages/movies.tsx`** uses `.View()` to create the component. `useReload` is called inside the render function, which runs inside the loader's boundary: ```tsx import { definePage, useReload } from 'hono-preact'; import { serverLoaders } from './movies.server.js'; const dataLoader = serverLoaders.default; const MoviesView = dataLoader.View(({ data }) => { const { reload, reloading } = useReload(); const handleAdd = async () => { await addMovie({ title: 'New Movie' }); reload(); }; return ( <>
    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
); }); export default definePage(MoviesView); ``` **`src/routes.ts`** wires the URL to the view and its server module: ```ts { path: '/movies', view: () => import('./pages/movies.js'), server: () => import('./pages/movies.server.js'), } ``` `useReload` must be called inside a component rendered within a loader boundary (inside `.View()` or inside a `loader.Boundary`). Calling it outside throws an error. ## Background refresh Reload is a background refresh: the current content stays visible while the new data fetches. There is no Suspense fallback shown during reload. Use `reloading` to reflect the in-progress state in your UI. ## Three knobs, three behaviors The framework has three ways to invalidate or re-run a loader. They look similar at the call site but mean different things at runtime: | Knob | Triggers fetch now? | Clears cache? | Affects what? | | ----------------------------------------- | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `useReload().reload()` | **Yes** | Yes (writes fresh data on success) | The active page's loader (the one whose boundary you're inside). | | `loader.invalidate()` | No | Yes (drops the entry) | A specific loader's cache only. Next navigation that mounts the loader will refetch on cache miss. | | `useAction({ invalidate: 'auto' })` | **Yes** | Yes | After the action succeeds, re-runs the active page's loader (the one wrapping the `useAction` call). Equivalent to calling `useReload().reload()` inside `onSuccess`. | | `useAction({ invalidate: [refA, refB] })` | Sometimes | Yes | After the action succeeds, calls `.invalidate()` on each ref. If any ref is the active page's loader, ALSO re-runs that loader; sibling-page loaders just have their cache cleared and refetch on their next mount. | The mental model: **`invalidate`** is "mark stale, refetch lazily". **`reload`** is "fetch right now". `useAction`'s `'auto'` mode is sugar over the reload path; its array mode is sugar over `loader.invalidate()` calls plus an opportunistic reload if the active loader is in the list. A common surprise: `invalidate: 'auto'` is NOT a no-op even when the loader has no observable changes; it triggers a real network request through `/__loaders`. Use `invalidate: false` (the default) if you don't want a refetch after the action. ## API ```ts const { reload, reloading } = useReload(); ``` | Value | Type | Description | | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reload` | `() => void` | Re-runs the `serverLoader`. If called while a fetch (initial load or a previous reload) is still in flight, the call is queued and runs once the in-flight fetch settles; concurrent calls coalesce into a single queued run. | | `reloading` | `boolean` | `true` while the loader is fetching, `false` otherwise. | ## See also - [Server Loaders](/docs/loaders): `loader.invalidate()` in context. - [Server Actions](/docs/actions): `useAction` invalidate option. - [Loading States](/docs/loading-states): the fallback shown during a refetch. --- > Source: https://framework.sbesh.com/docs/prefetch # Prefetching Data `prefetch` runs a loader and fills its cache ahead of navigation, so the destination route renders immediately when the user arrives. Use it when you can predict the user's next move (hover on a link, scroll into view, idle after page load) and the loader is non-trivial. ## Basic usage ```ts import { prefetch } from 'hono-preact'; import { serverLoaders } from './pages/movie.server.js'; const movieLoader = serverLoaders.default; // On link hover, kick off a fetch for /movies/42 so the route is ready when clicked. function onMovieLinkHover(id: number) { prefetch(movieLoader, { url: `/movies/${id}`, route: '/movies/:id' }); } ``` The result is written into the loader's cache (when one is configured) and the next navigation reads from cache instead of refetching. ## Common patterns **Hover prefetch** is the typical case: bind to `onMouseEnter` on a link and call `prefetch` with the destination URL. **Idle prefetch** is useful for the next-most-likely route. Call `prefetch` from a `requestIdleCallback` after the current page settles. **Programmatic prefetch** works anywhere: a router transition listener, a search-as-you-type debounce, an intersection observer for cards scrolling into view. `prefetch` checks the loader's cache first and returns immediately when the destination is already warm. A hover handler firing repeatedly (mouse over → off → back over the same link), an intersection observer re-entering visibility, or duplicate idle-callback scheduling will not issue redundant network requests. The cache key combines path with whatever `searchParams` the loader's `params` option declares, so two prefetches for `/movies/41` and `/movies/42` each hit the network exactly once. ## Why pass `url` and `route`? Loaders receive a `RouteHook` location (`{ path, searchParams, pathParams }`). When your loader reads `location.pathParams.id` or `location.searchParams.q`, prefetching with no location would crash or return the wrong data. Pass the URL the user is about to navigate to, plus the route pattern, and `prefetch` derives a complete `RouteHook` for you: - `url` populates `path` (trailing slash stripped, root preserved) and `searchParams`. - `route` is the matching route pattern (e.g. `/movies/:id`); when present, `pathParams` is derived by matching `url` against it. If you don't read any of those fields, omit both arguments and `prefetch(loader)` still works. ## Options | Option | Type | Description | | ---------- | ---------------- | ------------------------------------------------------------------------------------------ | | `url` | `string` | Target URL or path. Drives `path` and `searchParams`. | | `route` | `string` | Route pattern (e.g. `/movies/:id`). Combined with `url` to derive `pathParams`. | | `location` | `RouteHook` | Escape hatch: pass a fully-formed `RouteHook` directly. Overrides `url`/`route`. | | `cache` | `LoaderCache` | Override the cache the result is written to. Defaults to the cache attached to the loader. | ## See also - [Server Loaders](/docs/loaders): the loader cache prefetch fills. - [Link Prefetch](/docs/link-prefetch): the declarative `data-prefetch` attribute. - [Loading States](/docs/loading-states): what prefetching lets you skip. --- > Source: https://framework.sbesh.com/docs/streaming # Streaming Loaders & Actions Reach for streaming when the data changes while the user is looking at it: dashboards, log tails, chat token streams, progressive search results. If your data is stable for the lifetime of a request, a plain async function is simpler and should be your first choice. Streaming loaders and actions are async generators that yield values over time. The consumer API (`loader.useData()`, `useAction`) is the same as for static loaders; only the author shape changes. ## Streaming loaders ### Author shape A streaming loader is an `async function*` that yields `T` and receives a `LoaderCtx`: ```ts // src/pages/dashboard.server.ts import { defineLoader, type LoaderCtx } from 'hono-preact'; export type Snapshot = { count: number; load: number }; export const serverLoaders = { default: defineLoader(async function* ( ctx: LoaderCtx ): AsyncGenerator { while (!ctx.signal.aborted) { yield await currentSnapshot(); await new Promise((r) => setTimeout(r, 1000)); } }), }; ``` `ctx.signal` is an `AbortSignal` that fires when the client disconnects or the component unmounts. Thread it into upstream `fetch` calls and subscriptions so they clean up promptly. Returning from the generator (rather than looping) also ends the stream cleanly. For byte-level piping (e.g. forwarding a server-sent event feed without parsing), you can return a `ReadableStream` instead of an `AsyncGenerator`. The framework pipes it straight to the response. This is an escape hatch; for typed structured data, use an async generator. ### Consumer shape Use `.View()` to create a component that re-renders on each new chunk. The render function receives the latest `data` value plus `error` and `reload`: ```tsx // src/pages/dashboard.tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './dashboard.server.js'; const dataLoader = serverLoaders.default; const DashboardView = dataLoader.View( ({ data, error, reload }) => ( <> {error &&

Live updates paused: {error.message}

}

Count: {data.count}, load: {data.load}

), { fallback:

Loading...

} ); export default definePage(DashboardView); ``` `loader.useData()` always returns the latest yielded value. `loader.useError()` returns the current error or `null`. Components re-render on each new chunk automatically. See `/demo/projects/:projectId/issues/:issueId` for a running example of multi-loader streaming: the issue detail page declares multiple loaders in `serverLoaders`, each with its own `.View()` component streaming independently. ## Multi-loader streaming When a page declares multiple loaders in `serverLoaders`, each `.View()` component streams independently. They share no boundary; one loader finishing does not unblock another. ```ts // src/pages/movie.server.ts import { defineLoader } from 'hono-preact'; export const serverLoaders = { summary: defineLoader(async ({ location }) => getMovie(location.pathParams.id) ), cast: defineLoader(async function* ({ location }) { for await (const member of streamCast(location.pathParams.id)) yield member; }), }; ``` ```tsx // src/pages/movie.tsx import { definePage } from 'hono-preact'; import { serverLoaders } from './movie.server.js'; const { summary, cast } = serverLoaders; const Summary = summary.View(({ data }) => , { fallback: , }); const Cast = cast.View(({ data }) => , { fallback: , }); function MovieDetail() { return (
); } export default definePage(MovieDetail); ``` `` and `` each own their Suspense boundary. `` can paint immediately while `` is still streaming. Each `.View()` component gets a separate streaming-SSR registry key so the server can flush chunks for both concurrently. ## What happens on first paint 1. The server runs each `serverLoader` function and renders the page with first chunks for all loaders. The response stays open. 2. As subsequent chunks arrive, the server flushes `` tags inline into the ongoing HTML response, keyed per loader. 3. The browser receives and executes those tags as they arrive. On hydration, the client picks up from the latest pushed value for each loader and continues listening. The first-load experience is full SSR with progressive enhancement: the user sees real data immediately, and it keeps updating without a separate fetch or WebSocket. ## Errors **Before the first chunk:** the error propagates through the normal Suspense and error boundary path. If you provide `errorFallback` in `.View()`, it renders instead of the content. Otherwise the error boundary above catches it. **After the first chunk:** the stream is already open and the page is rendered. `error` in the `.View()` render function surfaces the error; `data` keeps returning the last good value. The page stays mounted with stale data visible. ```tsx const StatsView = dataLoader.View(({ data, error }) => ( <> {error &&

Live updates paused: {error.message}

}

Visitors: {data.visitors}

)); ``` ## Abort and cleanup The framework aborts `ctx.signal` when the client disconnects (server side) or when the component that owns the loader unmounts (client side). Pass the signal to any upstream resource that supports cancellation: ```ts const serverLoaders = { feed: defineLoader(async function* (ctx) { const res = await fetch('https://api.example.com/feed', { signal: ctx.signal, }); for await (const chunk of parseStream(res.body!)) { yield chunk; } }), }; ``` Polling loops should check `ctx.signal.aborted` at the top of each iteration (as in the example above) rather than listening for the abort event, so cleanup happens at a natural yield point. ## Streaming actions An action can be a streaming generator that yields progress chunks and returns a final result. The type parameters on `defineAction` follow the generator's shapes automatically. ### Author shape ```ts // src/pages/watched.server.ts import { defineAction } from 'hono-preact'; export const serverActions = { bulkImport: defineAction(async function* (ctx, payload: { count: number }) { for (let i = 0; i < payload.count; i++) { if (ctx.signal.aborted) return { imported: i }; await processItem(i); yield { count: i + 1, total: payload.count }; await new Promise((r) => setTimeout(r, 150)); } return { imported: payload.count }; }), }; ``` Yielded values are the chunk type (`TChunk`). The return value is the final result (`TResult`). TypeScript infers both from the generator body. ### Consumer shape ```tsx const [progress, setProgress] = useState<{ count: number; total: number; } | null>(null); const { mutate, data, pending } = useAction(serverActions.bulkImport, { onChunk: (p) => setProgress(p), onSuccess: (r) => console.log(`imported ${r.imported}`), }); ``` `onChunk` receives each typed chunk. `onSuccess` receives the typed final result (the generator's return value). `data` holds the final result after the stream closes. For error handling, pass `onError`: ```tsx const { mutate } = useAction(serverActions.bulkImport, { onChunk: (p) => setProgress(p), onSuccess: (r) => setProgress(null), onError: (err) => console.error('import failed', err), }); ``` Chunks and the final result are delivered in order. If the generator throws, the stream closes and `onError` is called; `onSuccess` does not fire. ## Form limitations for streaming actions Streaming actions cannot be used with ``. The type signature of `FormProps['action']` constrains the stub to non-streaming actions (`TChunk = never`), so passing a streaming action stub is a TypeScript error at compile time. If a streaming action receives a raw POST without `Accept: text/event-stream` (for example, a no-JS form submission), the server responds with HTTP 405. Streaming actions are only invocable via `useAction(stub)` with the `onChunk` callback. ```tsx // This is a type error: streaming actions are not accepted by ; // TS error // Correct: call streaming actions programmatically const { mutate } = useAction(serverActions.bulkImport, { onChunk: (p) => setProgress(p.count), }); ``` ## Debugging Streaming loaders and actions use a server-sent event (SSE) wire format. You can inspect the framing directly with curl: ```bash # Streaming loader endpoint curl -N 'http://localhost:5173/demo/projects/inf/tasks/t-1' # Streaming action: must include Accept: text/event-stream (replace module key and action name) curl -N -X POST http://localhost:5173/movies \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{"module":"pages/movies.server","action":"bulkImport","payload":{"count":5}}' ``` Each chunk arrives as a `data:` line followed by a blank line (standard SSE framing). The final result for actions arrives as a `result:` line. Parsing is handled automatically by the framework on the client. ## See also - [Server Loaders](/docs/loaders): non-streaming loaders. - [Server Actions](/docs/actions): streaming actions. - [Loading States](/docs/loading-states): fallbacks while a stream fills. --- > Source: https://framework.sbesh.com/docs/live-loaders # Live Loaders & Persistent UI A live loader is a streaming loader that connects once and survives intra-scope navigation. It is the mechanism for building UI that persists visually across route changes: a notification bar, a sidebar feed, a presence indicator, or any widget that should keep running while the user navigates within a section of the app. ## Example: activity bar The demo at `/demo/projects` uses a live activity loader on the `projects-shell` layout to show a real-time event feed across all project pages. **Server module** (`projects-shell.server.ts`): ```ts import { defineLoader, type LoaderCtx } from 'hono-preact'; import { subscribeActivity, recentActivityEvents, type ActivityEvent, } from './activity-stream.js'; async function* activityStream( ctx: LoaderCtx ): AsyncGenerator { // Yield recent events first so the feed is not empty on connect. for (const e of recentActivityEvents(5)) yield e; const queue: ActivityEvent[] = []; let wake!: () => void; let wakeP = new Promise((r) => (wake = r)); const unsub = subscribeActivity((e) => { queue.push(e); wake(); }); ctx.signal.addEventListener('abort', () => { unsub(); wake(); }); while (!ctx.signal.aborted) { while (queue.length) yield queue.shift()!; await wakeP; wakeP = new Promise((r) => (wake = r)); } } export const serverLoaders = { default: defineLoader(shellLoader), activity: defineLoader(activityStream, { live: true }), }; ``` **Layout component** (`projects-shell.tsx`): ```tsx import type { StreamStatus } from 'hono-preact'; import { serverLoaders } from './projects-shell.server.js'; import type { ActivityEvent } from './activity-stream.js'; const activityLoader = serverLoaders.activity; const MAX = 50; function Feed({ events, status, }: { events: ActivityEvent[]; status: StreamStatus; }) { const connected = status === 'open'; return (
{events.length === 0 ? (

Listening for activity...

) : (
    {events.map((e) => (
  • {e.actor}: {e.taskTitle}
  • ))}
)}
); } // The accumulating `.View` form (selected by `initial` + `reduce`) renders // through the loader's Suspense boundary, so the bar hydrates cleanly inside the // layout. `fallback` shows on SSR and until the first chunk; then `render` // receives the folded `data` and the connection `status`. const ActivityBar = activityLoader.View( ({ data, status }) => , { initial: [], reduce: (acc, e) => [e, ...acc].slice(0, MAX), fallback:

Connecting to activity...

, } ); export default function ProjectsShell({ children, }: { children: ComponentChildren; }) { return (
{children}
); } ``` Navigating between `/demo/projects/alpha` and `/demo/projects/beta` does not remount `ProjectsShell`, so `ActivityBar` keeps its accumulated events and the stream stays open. ## How it works Persistent UI is expressed as a child of a layout. A layout stays mounted across navigations between its child routes, so anything rendered inside the layout survives those navigations too. Attach a `live` loader to the layout's server module to connect a long-lived stream to that layout, and consume the stream with `loader.View(render, { initial, reduce })` inside a layout component. The stream connects once when the layout mounts and reconnects only when the user leaves the layout's scope entirely. Two pieces of the same loader API drive this pattern: - `defineLoader(fn, { live: true })` marks a loader as live. A live loader is never invoked during SSR (an infinite generator cannot hang the document response), and its timeout defaults to `false` (no 30-second cap) unless `timeoutMs` is set explicitly. A live loader is consumed with the accumulating `loader.View(render, { initial, reduce })` form. The single-value `.View(render)` form, `.useData()`, and `.Boundary` are not available on a live loader (it has no single value): the form is fixed by the loader's type (`defineLoader({ live: true })` returns a `LoaderRef`), so using the wrong form is a compile error. - `loader.View(render, { initial, reduce, fallback })` is the accumulating form of the standard consumption convention. It folds every chunk into accumulated state and renders through the loader's Suspense boundary, so a live widget hydrates cleanly inside a lazy layout: `fallback` renders during SSR and until the first chunk arrives, then `render` receives the accumulated `data` plus a `status`. ## On Cloudflare `publish()` and `route.liveLoader` have the same API on Cloudflare Workers. Cross-isolate fan-out (a `publish()` in one request waking a live loader streaming in another isolate) is backed by the same `HONO_PREACT_REALTIME` Durable Object that powers rooms, so no extra setup is needed once that binding is in your `wrangler.jsonc` (see the rooms docs, "Cloudflare setup"). One thing to keep in mind: `publish()` syncs the **event**, not your state. It fans a "something changed, re-run" wake out to every connected live loader; each loader then re-runs its `load(ctx)` and reads the current value. So your `load` must read from a **shared source of truth** that every isolate can see: a database, KV, D1, or Durable Object storage. A module-level `let count = 0` works on Node (one process shares it) but is **per-isolate on Workers**, so two tabs would drift apart even though the wake reached both. Read shared state in `load`, and `publish()` after you write it. ## Scoping the persistence Choose the layout based on the scope you want: | Scope | Route table entry | What persists | | --------------- | ------------------------------------------------- | --------------------- | | App-wide | Root route with `layout` and `path: '*'` children | Across the whole app | | Section | Prefix route group (e.g. `path: '/projects'`) | Within `/projects/**` | | Single sub-tree | Any nested layout group | Within that sub-tree | The stream is tied to the layout's lifecycle. It connects when the layout mounts and is aborted (via `ctx.signal`) when the layout unmounts, which happens when the user navigates outside the layout's scope. ## Known behavior ### Scope-exit blip When the user navigates out of the layout's scope, the layout unmounts and the stream closes. If the user navigates back in, the layout remounts and the stream reconnects from scratch, starting with `initial` again. This is the expected lifecycle: there is no cross-mount cache for live loaders. ### No automatic reconnect on drop A live loader opens the stream once and does not auto-reconnect. If the connection drops mid-session (a network blip, a server restart, an idle proxy timeout), `status` becomes `'error'` or `'closed'` and stays there until the layout unmounts and remounts (scope exit and re-entry). To recover in place, branch on `status` and call `reload()` (for example, a "Reconnect" button shown when `status === 'error'`); `reload()` resets `data` to `initial` and re-opens the stream. ### Failure or close before the first chunk The `status` field reports `'error'` / `'closed'` for failures and clean ends that happen _after_ the first chunk arrives. On the **initial** connect, a stream that fails, times out, or closes with zero chunks _before_ its first chunk is delivered through the loader's error boundary instead: the `errorFallback` renders (or the error propagates to the nearest boundary), and the render function's `status` is not updated. Live loaders normally yield immediately on connect (e.g. a backfill of recent events), so this only affects empty or pre-first-chunk-failing streams. A failed `reload()` behaves differently and more conveniently: because the component is already mounted, a reconnect that fails (at any point, including before its first chunk) surfaces as `status === 'error'` in the render function, not through the error boundary. That is exactly what makes the reconnect-on-error pattern above work in place. ## API reference ### `defineLoader(fn, { live })` | Option | Type | Default | Description | | ------ | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `live` | `boolean` | `false` | Marks this loader as a long-lived client subscription. Skipped on SSR; timeout defaults to `false`. Consume via the accumulating `loader.View` form. | All other `defineLoader` options (`params`, `cache`, `timeoutMs`, `use`) work the same for live loaders. See [Server Loaders](/docs/loaders) for the full option table. ### `loader.View(render, { initial, reduce })` (accumulating form) Passing `initial` and `reduce` selects the accumulating form of `.View`. `data` becomes the folded accumulator and the render args carry a `status`. ```ts loader.View( render: (args: { data: Acc; status: StreamStatus; error: Error | null; reload: () => void; }) => ComponentChildren, opts: { initial: Acc; reduce: (acc: Acc, chunk: Serialize) => Acc; fallback?: ComponentChildren; errorFallback?: ComponentChildren; } ): FunctionComponent ``` `chunk` is `Serialize`: the JSON round-trip of the server-side chunk, the same wire shape `useData()` and the single-value `.View` surface (a `Date` field arrives as a string). | Option | Type | Description | | ---------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `initial` | `Acc` | Seed value; also the value rendered on SSR and before the first chunk arrives. | | `reduce` | `(acc: Acc, chunk: Serialize) => Acc` | Folds each incoming chunk (the JSON wire shape) into the accumulated value. Called for every chunk in order. | | `fallback` | `ComponentChildren` | Rendered on SSR and while the stream is connecting (before the first chunk). | The `render` function receives: | Field | Type | Description | | -------- | --------------- | ------------------------------------------------------------------------------------------------- | | `data` | `Acc` | Current accumulated value. Starts as `initial`, updated after each chunk. | | `status` | `StreamStatus` | Connection state: `'connecting'`, `'open'`, `'closed'`, or `'error'`. | | `error` | `Error \| null` | Set when `status === 'error'`. | | `reload` | `() => void` | Resubscribe: aborts the current stream, resets `data` to `initial`, reconnects, and folds afresh. | `StreamStatus` values: | Value | Meaning | | -------------- | ------------------------------------------------------------- | | `'connecting'` | SSR or pre-hydration; the first chunk has not arrived yet. | | `'open'` | At least one chunk has arrived; the stream is active. | | `'closed'` | The generator returned normally; no more chunks are expected. | | `'error'` | The stream errored. `error` holds the cause. | ### Type exports ```ts import type { StreamStatus } from 'hono-preact'; ``` ## See also - [Server Loaders](/docs/loaders): non-live loaders and the full `defineLoader` option table. - [Layouts & Nested Routes](/docs/layouts): how layout scoping and lifecycle work. - [Streaming](/docs/streaming): finite streaming loaders consumed with the single-value `.View()` form. --- > Source: https://framework.sbesh.com/docs/realtime # Realtime Channels Realtime channels let you push live data from the server to the client without polling. A `Channel` is a typed address; `publish(topic, message)` fires from a server action after a mutation; `route.liveLoader({ topic, load })` subscribes any connected client to that topic and pushes a fresh data snapshot on every publish. ## Example: live shared counter A single-instance signal channel (no route params, no payload) that signals all live loaders to refetch the current count. **`counter-channel.ts`** (shared between server and action): ```ts import { defineChannel } from 'hono-preact'; // A signal channel: defineChannel('name')(). No payload; the subscriber // calls load() for the fresh value. Published with no message argument. export const counterChannel = defineChannel('counter')(); ``` **`counter.server.ts`** (server module for the `/counter` route): ```ts import { serverRoute } from 'hono-preact'; import { counterChannel } from './counter-channel.js'; import { getCount } from './counter-db.js'; const route = serverRoute('/counter'); export const serverLoaders = { count: route.liveLoader({ // topic(ctx) returns the Topic this loader subscribes to. topic: (_ctx) => counterChannel.key(), // load(ctx) is called on connect and on every publish to the topic. load: async (_ctx) => getCount(), }), }; ``` **`counter.tsx`** (page component): ```tsx import type { StreamStatus } from 'hono-preact'; import { serverLoaders } from './counter.server.js'; const countLoader = serverLoaders.count; // The accumulating .View form folds every pushed chunk into `data`. // `initial` seeds the value before the first chunk arrives; // `reduce` folds each chunk. For a simple replace-on-every-push pattern, // reduce just returns the latest chunk. const CountDisplay = countLoader.View( ({ data, status }) => (

Count: {data}

Status: {status}

), { initial: 0, reduce: (_acc, chunk) => chunk, fallback:

Connecting...

, } ); export default function CounterPage() { return (
); } ``` **`counter.action.ts`** (server action that mutates and publishes): ```ts import { defineAction } from 'hono-preact'; import { publish } from 'hono-preact'; import { counterChannel } from './counter-channel.js'; import { incrementCount } from './counter-db.js'; export const increment = defineAction(async () => { await incrementCount(); // Signal channel: no message argument. publish(counterChannel.key()); }); ``` Every client with the counter page open receives the new count within milliseconds of the action completing, without polling. ## How it works Three pieces cooperate to wire up a live shared counter (or any data that changes on server events): 1. **Define a channel** with `defineChannel`. The name uses the same `/:param` grammar as route paths; `channel.key(params)` builds a branded `Topic` that ties publish and subscribe together at the type level. 2. **Subscribe** from a `serverLoaders` entry using `route.liveLoader({ topic, load })`. The loader runs `load` once on connect and again on every publish to `topic`. The framework streams the results to the client over SSE so you consume them with the accumulating `loader.View(render, { initial, reduce })` form. 3. **Publish** from a server action by calling `publish(channel.key(params), message)`. Every connected live loader subscribed to that topic re-runs `load` and pushes the new value. ## Parameterized channels When the same data shape is segmented per resource, include the resource id in the channel name: ```ts import { defineChannel, type Channel, type Topic } from 'hono-preact'; // Type is Channel<'board/:boardId', { taskId: string; to: string }> export const boardChannel = defineChannel('board/:boardId')<{ taskId: string; to: string; }>(); // In a server action: boardChannel.key({ boardId }) is a Topic<{ taskId, to }> import { publish } from 'hono-preact'; publish(boardChannel.key({ boardId: 'b1' }), { taskId: 't7', to: 'done' }); ``` The `topic` function in `liveLoader` receives the same `ctx` as `load`, so it can read `ctx.location.pathParams` to key the subscription to the route: ```ts const route = serverRoute('/board/:boardId'); export const serverLoaders = { tasks: route.liveLoader({ topic: (ctx) => boardChannel.key({ boardId: ctx.location.pathParams.boardId }), load: async (ctx) => getTasks(ctx.location.pathParams.boardId), }), }; ``` ## Cross-connection fan-out Live loaders are server-to-client over SSE. Publishing to a topic fans out to every live loader subscribed to that topic. On Node, the in-process bus reaches all connections on the same instance. On Cloudflare Workers, each request runs in an isolated Worker instance, so fan-out is backed by a Durable Object: a subscribe holds a Worker-to-DO socket and `publish()` POSTs to the topic's DO, which fans the event out to every subscriber across isolates. This uses the same `HONO_PREACT_REALTIME` Durable Object binding rooms use; see [Cloudflare setup](/docs/rooms#cloudflare-setup) for the binding. Note `publish()` syncs the event, not your state: subscribers re-run their `load()` to read the current shared state. ## API reference ### `defineChannel(name)()` Defines a typed channel. The name uses the `/:param` grammar. The `Payload` type parameter sets the message type. A `void` payload (the default) is a signal channel that publishes with no message. ```ts const c = defineChannel('board/:boardId')<{ taskId: string }>(); ``` | | Type | Description | | --------- | ---------- | -------------------------------------------------------------------- | | `name` | `string` | Channel address, e.g. `'board/:boardId'`. Params use `:name` syntax. | | `Payload` | type param | Message type. Defaults to `void` (signal channel, no message). | Returns a `Channel` with one method: | Method | Signature | Description | | ---------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `channel.key(params?)` | `(...args) => Topic` | Builds a branded `Topic`. For a param-less name the argument is omitted; for a name with params the argument is `{ [paramName]: string }`. | ### `publish(topic, message?)` Publishes to a typed topic from a server action or server agent. Every live loader subscribed to `topic` re-runs its `load` and pushes the result to connected clients. ```ts import { publish } from 'hono-preact'; publish(boardChannel.key({ boardId }), { taskId, to }); // payload channel publish(counterChannel.key()); // signal channel ``` | Argument | Type | Description | | --------- | ---------- | -------------------------------------------------------------------- | | `topic` | `Topic

` | The topic to publish to. Built with `channel.key(params)`. | | `message` | `P` | Required for payload channels; omitted for `void` (signal) channels. | ### `route.liveLoader({ topic, load })` Defines a channel-driven live loader inside a `serverLoaders` object. Yields the result of `load` once on connect, then re-runs and pushes on every publish to `topic`. | Option | Type | Description | | ----------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------- | | `topic` | `(ctx: LoaderCtx) => Topic` | Returns the topic this loader subscribes to. Called with the same context as `load`. | | `load` | `(ctx: LoaderCtx) => Promise` | Produces the data snapshot. Called on connect and on every publish. | | `use` | `ReadonlyArray` | Optional guard/middleware chain run on the subscription (same inheritance as a non-live loader's `use`). | | `cache` | `LoaderCache` | Optional cache shared across `load` calls. | | `timeoutMs` | `number \| false` | Timeout per `load` call. Defaults to `false` (no cap). | Returns a `LoaderRef`. Consume it with the accumulating form `ref.View(render, { initial, reduce })`. The `StreamStatus` and `.View` option table are described on the [Live Loaders](/docs/live-loaders) page. ### Type exports ```ts import type { Channel, Topic, LiveLoaderOptions } from 'hono-preact'; import type { StreamStatus } from 'hono-preact'; ``` ## See also - [Live Loaders](/docs/live-loaders): the persistent-layout streaming pattern, `.View` accumulating form, and `StreamStatus` reference. - [Server Loaders](/docs/loaders): non-live loaders and the full `defineLoader` option table. - [Server Actions](/docs/actions): where `publish` is typically called. --- > Source: https://framework.sbesh.com/docs/actions # 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](/docs/streaming). ## Defining actions **`src/pages/movies.server.ts`** (assumes `type MovieList = { results: { id: number; title: string }[] }`): ```ts 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 `` can infer the payload and result types without codegen. ### `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`. ## 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`](/docs/render-page) for the manual wiring contract. ## Calling from a form: `` `` is the primary way to invoke an action. Pass the action stub directly to the `action` prop. On the client, `` 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()`. ```tsx import { definePage, Form } from 'hono-preact'; import { serverLoaders, serverActions } from './movies.server.js'; const dataLoader = serverLoaders.default; const AddMovieForm = () => ( ); const MoviesView = dataLoader.View(({ data }) => (

    {data.movies.results.map((m) => (
  • {m.title}
  • ))}
)); export default definePage(MoviesView); ``` `
` accepts any HTML `` attribute (except `onSubmit`, which it owns), plus `action` (required). The wrapping `
` 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, ``) arrive as **arrays** in the order they appeared in the form. Declare the array fields in your `defineAction` payload type: ```ts 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](#file-uploads). ### Form lifecycle `` 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. | ```tsx setShowForm(false)} > ``` `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 `
` 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. ```tsx import { Form, useActionResult } from 'hono-preact'; import { serverActions } from './movies.server.js'; const AddMovieForm = () => { const result = useActionResult(serverActions.addMovie); return ( {result?.kind === 'deny' &&

{result.message}

} {result?.kind === 'deny' && result.data?.fieldErrors?.title && (

{result.data.fieldErrors.title}

)}
); }; ``` `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: ```ts 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. ```tsx 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 ( <> {error &&

{error.message}

} ); }; ``` `useAction` posts to the page URL with `Accept: application/json` and returns a discriminated result. The action body runs identically whether called from `
` or `useAction`. ### Options | Option | Type | Description | | ------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalidate` | `'auto' \| false \| LoaderRef[]` | `'auto'` re-runs the current page's `serverLoader` (via the `/__loaders` RPC in the browser). An array of [`LoaderRef`](/docs/loaders)s 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. 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](#streaming-responses). | ### Return value | Value | Type | Description | | --------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mutate` | `(payload) => Promise<{ ok: true; data: Serialize \| 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 \| null` | The last successful result, or `null`. Not set for streaming responses. | #### Chaining on success ```tsx 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 `
`, such as a global spinner or a disabled navigation item. ```tsx import { useFormStatus } from 'hono-preact'; import { serverActions } from './movies.server.js'; const SaveIndicator = () => { const { pending } = useFormStatus(serverActions.addMovie); return pending ?

Saving...

: 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: ```tsx 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](/docs/optimistic-ui) for the higher-level `useOptimisticAction` hook, which also integrates directly with ``. ## 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: ```tsx // 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 `LoaderRef`s, 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. ```tsx 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 `` serializes file inputs as `File` instances in the payload, file uploads work transparently when you pair it with `useAction`. **With ``:** add a file input; the framework detects the `File` value in the payload and sends FormData: ```tsx const UploadPosterForm = ({ movieId, setPosterUrl }) => ( ); ``` **With `useAction`:** include a `File` in the payload object: ```tsx 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: ```ts 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, ``) 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 `
` (a type error). See [Streaming: limitations](/docs/streaming#form-limitations) for details. ```ts // 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 }; }), }; ``` ```tsx // 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: ```ts // 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 action plus every loader under a route), declare `use` on the route node in `src/routes.ts`. See [Middleware](/docs/middleware) for the full chain model. ## 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: ```ts 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): ```ts 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` or `serverActions`. See [Overview: The server/client boundary](/docs#the-serverclient-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](/docs/csrf) for the full recipe. --- > Source: https://framework.sbesh.com/docs/validation # Validation The framework integrates with the [Standard Schema](https://standardschema.dev) spec so you can validate action payloads, form fields, and loader params using any compliant library: Zod, Valibot, ArkType, or any other implementation. The framework depends only on the types-only `@standard-schema/spec` package and never on a specific validator. ## Covered surfaces Validation covers four payload surfaces: - **Action payloads** via `defineAction(fn, { input })`: server-enforced; the handler receives the schema's output type. - **Form fields** via ``: opt-in client pre-validation before the POST fires. - **Loader search params** via `defineLoader(fn, { searchSchema })`: validates and coerces `ctx.location.searchParams`. - **Loader route params** via `defineLoader(fn, { paramsSchema })` (or `serverRoute(id).loader(fn, { paramsSchema })`): validates and coerces `ctx.location.pathParams`. **The server is always authoritative.** Client-side validation (the `` prop) is an opt-in enhancement that only works when the schema is importable by the browser. Both paths surface through the same field-error rendering API. **The framework does not coerce.** Standard Schema validates `Input -> Output` and the framework passes the raw payload as `Input`. Coercing FormData strings (e.g. `z.coerce.number()`) is the schema author's job. ## Action validation Pass `input` to `defineAction` to attach a schema. On the server, the framework runs the schema before calling your handler; if validation fails the handler never runs and the action returns a `deny(422)` with the issues embedded in `deny.data`. On success the handler receives the schema's output type (coercion is visible). ```ts // tasks.server.ts import { defineAction } from 'hono-preact'; import { z } from 'zod'; const NewTask = z.object({ title: z.string().min(1, 'Title is required'), priority: z.enum(['urgent', 'high', 'medium', 'low']), }); export const serverActions = { createTask: defineAction( async (_ctx, payload) => { // payload: { title: string; priority: 'urgent' | 'high' | 'medium' | 'low' } const task = await db.tasks.create(payload); return { id: task.id }; }, { input: NewTask } ), }; ``` The handler's `payload` type is inferred from the schema's output. No explicit type annotation is needed. ### Reading issues client-side `getValidationIssues(result)` pulls issues from a `useActionResult()` result when the result is a schema-validation failure. It returns `ValidationIssue[]` or `null` for non-validation denies. ```tsx import { Form, useActionResult, getValidationIssues } from 'hono-preact'; import { serverActions } from './tasks.server.js'; const CreateForm = () => { const result = useActionResult(serverActions.createTask); const issues = getValidationIssues(result); return ( {issues && (
    {issues.map((issue, i) => (
  • {issue.message}
  • ))}
)}
); }; ``` ## Form client pre-validation Pass `schema` to `
` to enable client-side pre-validation. When schema validation fails on submit, the POST is blocked and the field errors are stored in form-local state. Once a field has shown an error, it re-validates on input so the error clears as the user fixes it. Because `.server.*` files are stripped to typed proxies on the client, a schema passed to `` **must live in a shared (non-`.server`) module**. A schema used only server-side can be a local `const` inside the `.server.*` file. ```ts // tasks.schema.ts (shared module, not *.server.*) import { z } from 'zod'; export const NewTaskSchema = z.object({ title: z.string().min(1, 'Title is required'), priority: z.enum(['urgent', 'high', 'medium', 'low']), }); ``` ```ts // tasks.server.ts import { defineAction } from 'hono-preact'; import { NewTaskSchema } from './tasks.schema.js'; export const serverActions = { createTask: defineAction( async (_ctx, payload) => { const task = await db.tasks.create(payload); return { id: task.id }; }, { input: NewTaskSchema } ), }; ``` ```tsx // tasks.tsx import { Form, FieldError, useFieldErrorProps } from 'hono-preact'; import { serverActions } from './tasks.server.js'; import { NewTaskSchema } from './tasks.schema.js'; const TitleField = () => ( ); const CreateTaskForm = () => ( ); ``` Spreading `useFieldErrorProps('title')` onto the input associates it with its `` for assistive technology: when the field has an error the hook returns `aria-invalid` and an `aria-describedby` pointing at the error element's id (and nothing when the field is valid). Wire it on every field so screen-reader users hear the error tied to the control, not just an isolated alert. The `schema` prop is typed as `StandardSchemaV1` where `TPayload` is the action's inferred payload type. Passing a schema that produces the wrong shape is a compile error, so the action and form cannot drift. ### Rendering field errors **``** renders the first error message for a field, or nothing. It is a thin convenience wrapper around `useFieldErrors()`. ```tsx {/* Form-level errors (issues with no field path) */} ``` **`useFieldErrors()`** returns the full `FieldErrorsMap` (a `Record`) for custom rendering. It reads from form context and is only useful inside a `
`. ```tsx import { useFieldErrors } from 'hono-preact'; const TitleField = () => { const errors = useFieldErrors(); const titleErrors = errors['title'] ?? []; return ( ); }; ``` **`useFieldErrorProps(name)`** returns the ARIA props to spread onto a field control (``/`
); }; ``` `value` is the projection: `base` with all in-flight payloads applied via `apply`. While the mutation is in flight, `addMovie.value` includes the optimistic entry; after the server responds and the loader refetches (`invalidate: 'auto'`), `addMovie.value` reflects real server data with no visual gap. The returned object is stub-compatible: pass it to `
` for declarative form submission, or call `addMovie.mutate(payload)` for programmatic invocation. Both paths participate in the optimistic queue. Access `addMovie.pending`, `addMovie.error`, and `addMovie.data` to read the mutation status and result. ### Options | Option | Type | Description | | ------------ | -------------------------------- | ------------------------------------------------------------------------------------------------- | | `base` | `TBase` | The base value (typically loader data) the projection layers over | | `apply` | `(current, payload) => TBase` | Reducer that produces the next projection | | `invalidate` | `'auto' \| LoaderRef[]` | Refetch trigger after mutation succeeds. `false` is intentionally not allowed (see below). | | `onSuccess` | `(data) => void` | Called after a successful mutation. Snapshot is internal; not exposed here. | | `onError` | `(err) => void` | Called after a failed mutation. The optimistic entry is reverted automatically before this fires. | Other `useAction` options (`onChunk`) pass through. ### Why no `invalidate: false`? The optimistic entry settles into `'ready'` state on success and waits for the base to update before evicting. Without an invalidation that refetches, the base never changes, the entry lingers, and the UI gets stuck. Use `useOptimistic` directly if you have a use case where base updates by another path. ## `useOptimistic` (primitive) ```tsx import { useOptimistic, useAction } from 'hono-preact'; const Movies = ({ loaderData }) => { const [movies, addOptimistic] = useOptimistic( loaderData.movies, (current, payload) => [...current, payload] ); const { mutate } = useAction(serverActions.create, { invalidate: 'auto', onMutate: (payload) => addOptimistic(payload), onSuccess: (_data, handle) => handle.settle(), onError: (_err, handle) => handle.revert(), }); return (
    {movies.map((m) => (
  • {m.title}
  • ))}
); }; ``` `addOptimistic(payload)` appends a queue entry and returns an `OptimisticHandle`: ```ts type OptimisticHandle = { settle: () => void; // success: linger until base ref changes revert: () => void; // error: remove immediately }; ``` The handle becomes the snapshot in `useAction`'s `onMutate`/`onSuccess`/`onError` chain. ## Concurrent mutations Both APIs handle concurrent mutations correctly. If a user fires two mutations and the first completes before the second, the second's optimistic entry survives the first's settle-and-refetch: ``` queue=[A:active, B:active] → A succeeds, A.settle() queue=[A:ready, B:active] → loader refetches, base updates (A confirmed) → A:ready evicted (base ref changed), B:active stays queue=[B:active] → UI shows server-confirmed A + optimistic B ``` No special configuration needed. ## Composing with `` `useOptimisticAction` returns a stub-compatible value that you can pass directly to ``. The result carries the action's type brand, so the form knows how to invoke it and TypeScript enforces the payload shape. ```tsx const NotesForm = ({ defaultNotes }) => { const notesAction = useOptimisticAction(serverActions.setNotes, { base: defaultNotes, apply: (_current, payload) => payload.notes, invalidate: 'auto', }); return ( <>

Current: {notesAction.value}