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.
Step 3: Link to it#
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?)#
| Param | Type | Description |
|---|---|---|
modules | Record<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.wrapper | ComponentType<{ children }> | Single-root wrapper around each page. Defaults to a bare <div>. |
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/<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#
- The Route Table: full reference for
defineRoutes. - Layouts & Nested Routes: when and how to share chrome across URLs.
- Server Loaders: what goes in the
.server.tsfile. - Middleware: the
usefield on a route node inroutes.tsfor auth gates and per-page middleware. - Active Links: linking between pages with active-state highlighting.