hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

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#

// 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:

PathBehaviour
/, /demo/login, /demo/projects/:projectIdPlain leaves. The view resolves to the page component; a colocated .server.ts sibling is auto-discovered.
/demo/projectsAn empty-path child of a layout group. Renders <ProjectsLayout> wrapping the list view.
/demo/projects/:projectId/issues/:issueIdA 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#

FieldTypeRequiredWhat it is
pathstringalwaysURL 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 leavesThe page component, behind a dynamic import. The framework wraps it with preact-iso's lazy for code-splitting.
layout() => Promise<{ default: ComponentType<LayoutProps> }>for layout groupsA wrapper component that receives children. See Layouts & Nested Routes.
server() => Promise<unknown> | falseoptional, advancedAdvanced. 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.
useMiddleware[]optionalAn array of page-layer middleware run for this node and all its descendants. See Middleware.
childrenRouteDef[]for layouts and path groupsNested routes.

The three valid shapes#

ShapeFieldsMeaning
Leafpath, view, optional serverA page. Cannot have children.
Layout grouppath, layout, children (required), optional serverA wrapper that renders matched descendants. May declare its own server for layout-scoped loaders.
Path grouppath, 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:

// 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 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.

// 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) 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 <Routes> inside <LocationProvider>; 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 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.

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:

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 for the full pattern.

What defineRoutes returns#

type RoutesManifest = {
  tree: ReadonlyArray<RouteDef>; // the original input, for introspection
  flat: ReadonlyArray<FlatRoute>; // the registered routes
  serverImports: ReadonlyArray<
    // every `server` thunk in the tree
    () => Promise<unknown>
  >;
  serverRoutes: ReadonlyArray<ServerRoute>; // server-bound nodes (loaders/actions/rooms/sockets)
  routeUse: ReadonlyArray<{
    path: string;
    use: ReadonlyArray<Middleware | StreamObserver>;
  }>; // 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 <Routes> 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 for the full model.

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.

See also#