hono-preact

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.

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