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

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:

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 <MoviesLayout><MoviesList /></MoviesLayout>.
  • /movies/123 matches the parent + the :id child. Renders <MoviesLayout><Movie /></MoviesLayout> 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:

// src/pages/movies-layout.tsx
import type { LayoutProps } from 'hono-preact';

export default function MoviesLayout({ children }: LayoutProps) {
  return (
    <section class="p-1">
      <header class="flex gap-2">
        <a href="/" class="bg-amber-200">
          home
        </a>
        <a href="/demo/projects" class="bg-emerald-200">
          projects
        </a>
      </header>
      <div class="mt-2">{children}</div>
    </section>
  );
}

children is the matched descendant tree. The framework injects it.

Typed route params#

pathParams is Record<string, string> 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:

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<typeof routeTree>;
  }
}

Then name the route you are on; the params are inferred from its pattern:

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 <h1>{id}</h1>;
}

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#

RuleWhy
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 exactlyThe "index" child of a layout group.
Routes match in source orderFirst match wins, including catchalls like *.
* matches anything not matched by siblingsWorks 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 for the full pattern.

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

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

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

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

<DashboardLayout>
  <ReportsLayout>
    <Report />
  </ReportsLayout>
</DashboardLayout>

Navigating /dashboard/reports → /dashboard/reports/42 remounts only <Report />. Both layouts stay mounted. Navigating /dashboard/reports/42 → /dashboard remounts everything below <DashboardLayout> (because <ReportsLayout> is no longer matched).

How the framework preserves identity#

preact-iso's <Router> 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 <Layout><Router>{...children}</Router></Layout>. The inner <Router> 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#

FeatureWorkaround
Pathless layout routes (no URL segment)Repeat the wrapper as a regular component imported by each view that needs it.
useParentLoaderData / loader compositionLayout 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 middlewareUse Hono .use() in your api.ts for path-prefix middleware.

Layout-level loaders DO run in parallel with leaf loaders (see 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:

// src/components/MarketingChrome.tsx
export function MarketingChrome({ children }: { children: ComponentChildren }) {
  return (
    <div class="marketing-bg">
      <nav>...</nav>
      {children}
    </div>
  );
}

// src/pages/about.tsx
import { MarketingChrome } from '../components/MarketingChrome.js';
export default function About() {
  return (
    <MarketingChrome>
      <h1>About</h1>
    </MarketingChrome>
  );
}

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#