hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
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
useSafeArea
usePresence

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#

import type { FunctionComponent } from 'preact';

const About: FunctionComponent = () => {
  return <section>About this app.</section>;
};

About.displayName = 'About';

export default About;

The page is a default-exported component. No <Page> 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#

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:

// src/pages/about.tsx
import { definePage } from 'hono-preact';
import { serverLoaders } from './about.server.js';

const AboutView = serverLoaders.default.View(({ data }) => (
  <section>{/* ... */}</section>
));

export default definePage(AboutView);
// 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.

From anywhere in the app:

<a href="/about">About</a>

preact-iso intercepts clicks on same-origin <a> 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 for the full pattern.

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

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 <article class="mdx-content">). 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 <div>.

contentRoutes(modules, options?)#

ParamTypeDescription
modulesRecord<string, () => Promise<unknown>>The map from import.meta.glob. Keys are file paths; each value is a lazy importer whose default export is the page component.
options.wrapperComponentType<{ children }>Single-root wrapper around each page. Defaults to a bare <div>.
options.slug(key: string) => stringMap a glob key to its route path, overriding the default rule.
options.basestringPrefix 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/<slug>.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 for the full toolkit (named elements, lifecycle hooks, direction-driven types, and persistent elements).

See also#