Layouts and error pages

Drop these files in app/routes/ and Chevalier picks them up — no wiring.

The app shell

_app.tsx is the document shell: the single <html><body> structure wrapping every page. There's one, app-root-only. Render <Head> for the <head> — it adds charset/viewport meta and your stylesheets, and you put the app-wide <title>, favicon, and any extra tags inside it — and {children} for the page. Omit the file to use the built-in shell.

// app/routes/_app.tsx
import { Head, type LayoutProps } from "chevalier";

export default function App({ children }: LayoutProps) {
  return (
    <html lang="en">
      <Head>
        <title>My App</title>
        <link rel="icon" href="/favicon.png" />
      </Head>
      <body>{children}</body>
    </html>
  );
}

Nested layouts

_layout.tsx is body-only chrome (nav, sidebar, footer) wrapping a page. Drop one at any level to wrap that directory and everything under it. Layouts nest: a route gets every ancestor _layout.tsx, outer→inner, each wrapping the next via {children}, all inside the app shell. A route with no _layout.tsx ancestor renders bare in the shell.

// app/routes/_layout.tsx
import type { LayoutProps } from "chevalier";

export default function Layout({ children }: LayoutProps) {
  return (
    <>
      <nav>…</nav>
      {children}
    </>
  );
}

The route prop

Every _layout.tsx — and the _app shell — also receives route, so chrome can react to the current page without reaching for the raw request:

  • route.url — the request path, e.g. /docs/layouts. What you match on to highlight the active nav link.
  • route.path — the matched route pattern, e.g. /docs/:slug. undefined on a 404 or error render, since no route matched.
  • route.data — the same { params, ...loaderData } object the page receives.
// app/routes/docs/_layout.tsx
import type { LayoutProps } from "chevalier";

export default function DocsLayout({ children, route }: LayoutProps) {
  return (
    <>
      <nav>
        {links.map((l) => (
          <a href={l.href} aria-current={route.url === l.href ? "page" : undefined}>
            {l.title}
          </a>
        ))}
      </nav>
      {children}
    </>
  );
}

Layouts render after the page, so a layout can't contribute to <head>: a <PageHead> rendered from a layout is silently dropped. Keep it in the page.

Per-page head

Render <PageHead> anywhere in a page's JSX to add tags to <head> — a <title>, meta, links. They land in the shell's <Head>, and a page <title> overrides the shell default:

import { PageHead } from "chevalier";

export default function About() {
  return (
    <>
      <PageHead>
        <title>About — My App</title>
        <meta name="description" content="…" />
      </PageHead>
      <h1>About</h1>
    </>
  );
}

Error pages

  • _404.tsx renders with status 404 for any unmatched route (and for a page's own c.notFound()).
  • _error.tsx renders with status 500 and receives the thrown error as a prop. The error is also logged server-side via console.error.

_app, _404, and _error are opt-in and app-root-only; omit any to fall back to the built-in shell / Hono's defaults. _layout.tsx and _middleware.ts are both per-directory.