> 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): switching on the `LoaderState` status to show skeletons and spinners. - [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. Run it with no arguments to use the interactive wizard, or pass a directory: ```bash pnpm create hono-preact my-app cd my-app ``` The wizard asks for the adapter (Cloudflare Workers or Node), whether to add `hono-preact-ui` components, and whether to install dependencies and init git. To script it, pass flags instead (`--adapter node`, `--ui`, `--yes`); with npm, put them after `--`. 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 the `routeTree` array in `src/routes.ts` (the scaffolded file declares the tree `as const` and registers it, so `useParams` and `buildPath` stay typed as the table grows): ```ts const routeTree = [ // ... existing routes { path: '/movies', view: () => import('./pages/movies.js') }, ] as const; ``` 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 }) => { if (!data) return

Loading...

; return (

Movies

    {data.movies.map((m) => (
  • {m.title}
  • ))}
); }); MoviesView.displayName = 'Movies'; export default definePage(MoviesView); ``` Register the route in `src/routes.ts`; the colocated `movies.server.ts` is wired in with it: ```ts { path: '/movies', view: () => import('./pages/movies.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)` returns a Preact component pre-wrapped in the loader's error boundary, data context, and reload context. The render function receives a `LoaderState` discriminated union on every render; switch on `status` (`loading | success | revalidating | error`) to render each state, and read `reload` from `useReload()`. `loader.useData()` inside the render function is argument-free and returns the same `LoaderState`. ## 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 }) => { if (!data) return

Loading...

; return (

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): switch on the `LoaderState` status to show a spinner or skeleton 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 routing 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') }, { path: ':projectId/issues/:issueId', view: () => import('./pages/issue.js'), }, ], }, { path: '/demo/projects/:projectId', view: () => import('./pages/project.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; a colocated `.server.ts` sibling is auto-discovered. | | `/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` \| `false` | optional, advanced | **Advanced.** The sibling `.server.ts` module behind a dynamic import (carries `serverLoaders` / `serverActions`). Usually omitted: the colocated sibling is auto-discovered. Set this only to point at a non-sibling module, or to `false` to opt out. See [Colocated server modules](#colocated-server-modules). | | `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`. ## Colocated server modules A route's server module (its `serverLoaders`, `serverActions`, rooms, and sockets) lives in a `.server.ts` file named after the route's `view`/`layout` file: `login.tsx` pairs with `login.server.ts`, `projects-shell.tsx` with `projects-shell.server.ts`. The build finds that sibling and wires it to the route: ```ts // login.tsx + login.server.ts sit side by side. { path: 'login', view: () => import('./pages/login.js') } ``` Middleware inheritance, the loader/action RPC map, and the boot-time route-binding check apply just as they do for an explicit `server:` thunk. In `vite dev`, each wired module is logged once. ### Advanced: the explicit `server` field Writing `server:` by hand is an advanced override for the cases discovery does not cover: - **Non-sibling module:** `server: () => import('./somewhere-else.server.js')` points the route at a specific module and wins over the sibling. - **`server: false`:** keeps a `.server.ts` sibling from being wired (e.g. a scratch file). Discovery reads the inline `() => import()` on the route, so it covers hand-written route tables. A route whose `view` is a hoisted const thunk, or one generated at runtime (e.g. `contentRoutes(import.meta.glob(...))`), takes an explicit `server:` if it needs one. If a `.server.*` file is imported by nothing (not a route, and not the [`src/server` registry](#the-srcserver-registry) below), the production build warns about it, so a misplaced module is not a silent runtime 404. ## The `src/server` registry Server modules that are not tied to a single page can live in `src/server/` instead of next to a view. Every `.server.*` file under it, at any folder depth, is globbed into the server build, so its units register without a `server:` route field. This is the place to organize route-less server code by domain (`src/server/billing/`, `src/server/audit/`). **Route-less** units are addressed by module key: a bare `defineLoader` (over the loaders RPC) or `defineAction` (via the handler's moduleKey fallback), plus rooms and sockets. Both are route-independent: their RPC runs only the app-level `use` and the unit's own unit-level `use`, never a route node's page-layer `use`, so gate a route-less unit with a unit-level `use` (or bind it with `serverRoute(r)`). A page reaches them by importing their `serverLoaders` / `serverActions` exactly as it would a route-attached module. ``` src/server/audit/log.server.ts -> export const serverLoaders = { recent: defineLoader(...) } ``` **Route-bound** units work here too: bind one to a real route with `serverRoute('/path')` and it inherits that route's page-layer `use` (auth) chain, wherever the file lives. This lets you keep a gated route's server logic under `src/server/` organized by domain rather than beside its view. ```ts // src/server/audit/project-activity.server.ts const route = serverRoute('/demo/projects/:projectId'); // inherits its requireSession gate export const serverLoaders = { activity: route.loader(async () => ({ events: [] })), }; ``` The bound pattern must be a real route in your table; a `serverRoute('/typo')` that matches no route fails loudly at startup rather than running under no gates. Any node with child routes (a layout, or a grouping node that declares only `use` and `children`) is bindable as a subtree: `serverRoute('/demo/projects/*')` resolves the chain every descendant inherits. Under a tree-form registration (`tree: typeof routeTree`, see [Typed route params](/docs/layouts#typed-route-params)) the subtree spelling is typed for every children-bearing node, grouping nodes included; a paths-only registration types subtree patterns heuristically from the path union, requiring a registered strict descendant. A layout's exact path is also bindable when the layout has an index child or a server module of its own; that spelling is the page scope, the pattern's deepest composed chain. A subtree pattern on a childless route fails loudly at startup. The folder is configurable via the Vite plugin's `serverDir` option (default `src/server`). ## 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. A custom client entry must call `bootClient()` before hydrating; see [Custom client entry path](/docs/vite-config#custom-client-entry-path). ## Inline imports stay inline Every `view` and `layout` (and any explicit `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; }>; // composed page-layer `use` chain, one per matchable route pattern }; ``` `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 to apply a route node's `use` to its actions and route-bound loaders by exact pattern. ## 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), to every action RPC, and to every route-bound loader RPC (`serverRoute(r).loader`) under that subtree. A bare `defineLoader` is the exception: its RPC is route-independent, so it inherits the guard on the SSR render but not on a direct `POST /__loaders` hit. Bind such a loader to its route (or give it a unit-level `use`) to gate it. See [Middleware](/docs/middleware) for the full model. ```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') }, { path: 'settings', view: () => import('./pages/settings.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 } 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 { tree: typeof routeTree; } } ``` 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. The tree form types the whole binding surface from the tree structure: the path union (what `RoutePaths` extracts) plus a subtree pattern (`/*`, what `RouteSubtrees` extracts) for every node with children, so `serverRoute`'s subtree scope compiles for grouping nodes and index-only layouts alike. A `paths: RoutePaths` registration is also supported; with only a path union to work from, it types subtree patterns heuristically, requiring a registered strict descendant. ## 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 }) => data ?
{/* render data */}
:

Loading...

); export default definePage(AboutView); ``` ```ts // src/routes.ts { path: '/about', view: () => import('./pages/about.js'), } ``` `about.server.ts` sits next to `about.tsx`, so its `serverLoaders` and `serverActions` are wired into the generated `server.tsx` (`createServerEntry`) automatically. See [Colocated server modules](/docs/routes#colocated-server-modules). ### 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: [ // `movies-list.server.ts` / `movie.server.ts` siblings are auto-discovered. { path: '', view: () => import('./pages/movies-list.js') }, { path: ':id', view: () => import('./pages/movie.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/head # Head Management Pages declare their document title, meta tags, link tags, and the `` attribute with head hooks imported from `hono-preact`. During SSR the framework collects every hook call made in the rendered tree and injects the tags into the document head, so titles and social metadata are present in the initial HTML; on the client the same hooks keep the head in sync as the user navigates. ## Example Set a title and description from any page component: ```tsx // src/pages/movie.tsx import { useTitle, useMeta } from 'hono-preact'; export default function Movie({ movie }: { movie: { title: string } }) { useTitle(movie.title); useMeta({ name: 'description', content: `Details for ${movie.title}` }); return

{movie.title}

; } ``` Set a site-wide title template once in a layout, and page titles slot into it: ```tsx // src/pages/layout.tsx import { useTitleTemplate, useLang } from 'hono-preact'; export default function Layout({ children }) { useTitleTemplate('%s | My App'); useLang('en'); return <>{children}; } ``` Add a link tag (a canonical URL, a preload, an icon): ```tsx import { useLink } from 'hono-preact'; useLink({ rel: 'canonical', href: 'https://example.com/movies' }); ``` ## How it works - **SSR injection.** `renderPage` collects every head hook call during prerender and post-processes the tags into your layout's ``. Your `Layout` must render a real `` element (the `` component from `hono-preact` provides one); with no `` in the output there is nowhere to inject and the tags are dropped. - **Fallback title.** When no page sets a title, the `` prop (or `renderPage`'s `defaultTitle` option) supplies one. - **Client navigation.** The hooks run on the client too: navigating to a page that calls `useTitle` updates `document.title` without a reload, and unmounting restores the previous value. - **Deepest call wins.** When a layout and a page both set the same field, the innermost (most recently rendered) hook call takes effect, so page-level titles override layout defaults. ## API reference | Hook | Signature | Description | | ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `useTitle` | `(title: string, template?: boolean) => void` | Sets `document.title`. Pass `template: true` to treat the string as a title template. | | `useTitleTemplate` | `(template: string) => void` | Declares a title template; `%s` is replaced by the current `useTitle` value. | | `useMeta` | `(options: MetaOptions) => void` | Renders a `` tag. `MetaOptions` carries `name`, `property` (Open Graph), `httpEquiv`, `charset`, and `content`. | | `useLink` | `(options: LinkOptions) => void` | Renders a `` tag. `LinkOptions` carries `rel` (required), `href`, `as`, `media`, `sizes`, `crossorigin`, `type`, and `hreflang`. | | `useLang` | `(language: string) => void` | Sets the `lang` attribute on ``. | | `useScript` | `(options: ScriptOptions) => void` | Renders a `