Layouts & route groups

layout.tsx, error.tsx, loading.tsx, not-found.tsx, route groups (group)/, and how the chain composes.

A layout wraps every page in its subtree. Drop a layout.tsx in a directory and every page below it gets wrapped — outer layouts compose around inner ones automatically.

The same pattern handles error boundaries (error.tsx), pending UI (loading.tsx), and 404 fallbacks (not-found.tsx). These are the four special files; discovery treats them differently from regular pages.

Root layout

// src/pages/layout.tsx — wraps EVERY page
import type { ReactNode } from 'react'
import './globals.css'

export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
  return (
    <div className="min-h-screen bg-background text-foreground">
      <header>{/* topbar */}</header>
      <main>{children}</main>
      <footer>{/* footer */}</footer>
    </div>
  )
}

Conventions:

  • Don't render <html>/<head>/<body>. The framework's index.html shell owns those. Rendering them inside React puts them under #root + the browser unwraps them, breaking the document structure.
  • Set <html class> via theme: in app.config.ts. Bakes the dark/light class into the shell before first paint.
  • Set <title> + meta via the page's meta export. See Loaders & meta.

Nested layouts

src/pages/
├── layout.tsx                # outer (every page)
├── about.tsx                 # /about → wrapped in outer layout
└── dashboard/
    ├── layout.tsx            # nested (only /dashboard/*)
    ├── index.tsx             # /dashboard
    └── settings.tsx          # /dashboard/settings

For /dashboard/settings, the React tree is:

<OuterLayout>
  <DashboardLayout>
    <Settings />
  </DashboardLayout>
</OuterLayout>

Outer layouts compose around inner ones. Each layout's state survives navigation within its scope — moving from /dashboard to /dashboard/settings doesn't unmount DashboardLayout.

Route groups

A directory in (parentheses) does NOT contribute a URL segment, but its layout still applies. Useful when you want a layout for a logical group of pages without nesting their URLs.

src/pages/
├── (marketing)/
│   ├── layout.tsx            # marketing-scoped chrome
│   ├── index.tsx             # /
│   ├── pricing.tsx           # /pricing
│   └── about.tsx             # /about
└── (app)/
    ├── layout.tsx            # authenticated app chrome
    ├── dashboard.tsx         # /dashboard
    └── settings.tsx          # /settings

Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.

Use this when:

  • The marketing landing + product app share root URL paths but have completely different chrome.
  • You want layout state to NOT persist across logical sections (moving from /about to /dashboard unmounts everything).

Error boundaries

// src/pages/error.tsx — catches errors from any page below
import type { ReactNode } from 'react'

interface ErrorProps {
  readonly error: Error
  readonly reset: () => void
}

export default function ErrorPage({ error, reset }: ErrorProps): ReactNode {
  return (
    <div className="text-center py-12">
      <h1 className="text-2xl font-bold mb-2">Something went wrong</h1>
      <p className="text-muted-foreground mb-6">{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}
  • Catches errors from page renders + loaders + any descendant's React tree.
  • reset() re-renders the boundary — call after fixing whatever caused the throw.
  • Scoped: src/pages/dashboard/error.tsx only catches errors from /dashboard/*.

Pending UI (opt-in)

Navigation is deferred by default: clicking a link keeps the CURRENT page on screen until the target route's loaders settle, then swaps. There is no full-screen loading overlay. Background progress shows in the devtools button (dev) or a minimal corner indicator (production) — both read the same status bus.

loading.tsx is an opt-in override for one route: export it (or a page-level Pending) ONLY when you want that route to swap in immediately and show a skeleton instead of holding the previous page.

// src/pages/dashboard/loading.tsx — opt-in skeleton for /dashboard/*
export default function Loading(): ReactNode {
  return <div className="animate-pulse">Loading…</div>
}

loading.tsx is driven by in-flight loaders, not by subscriptions — a useSubscription returns data: undefined until its first snapshot rather than suspending. Render that empty state inside the page itself.

Scoped like error.tsx.

Not-found

// src/pages/not-found.tsx — 404 fallback
export default function NotFound(): ReactNode {
  return (
    <div className="text-center py-12">
      <h1>Page not found</h1>
      <a href="/">← Home</a>
    </div>
  )
}

Scoped: src/pages/dashboard/not-found.tsx catches 404s only for URLs starting with /dashboard/. Useful for tenant-specific 404 messaging.

The default fallback chrome (and localizing it)

Until you supply your own error.tsx / not-found.tsx, the router renders a built-in diagnostic chrome — a runtime-error card (error name, stack, Retry / Reload / Copy) and a 404 card. It's a dev-diagnostic surface, not app UI, so it ships full detail regardless of environment. Two ways to change it:

  • Replace it — export your own error.tsx / not-found.tsx (above), or pass errorFallback / notFound components to the <Router>. Total control.
  • Just relabel it — wrap the app in <FallbackStringsProvider> to override the built-in chrome's English strings without rebuilding the components (e.g. to localize "Retry" / "Reload page" / "No page for this URL"):
import { FallbackStringsProvider } from '@voltro/web'

<FallbackStringsProvider
  strings={{
    error: { retry: 'Wiederholen', reload: 'Seite neu laden', copy: 'Fehler kopieren' },
    notFound: { heading: 'Keine Seite für diese URL' },
  }}
>
  {/* your app */}
</FallbackStringsProvider>

Overrides deep-merge onto the English defaults — supply only the keys you change; the rest stay English. A per-component strings prop on DefaultErrorFallback / DefaultNotFound wins over the provider for a one-off. (Mirrors <UiStringsProvider> for the @voltro/ui kit.)

How the framework composes them

For a request to /dashboard/settings, the framework walks the page tree:

  1. Find the matching leaf — dashboard/settings.tsx.
  2. Walk up the directory tree, collecting every directory's special files in order.
  3. Build the chain: outermost layout → next layout → … → leaf page.
  4. Wrap each layer's error.tsx as a React Error Boundary around the next layer.
  5. Render.

The generated .framework/app.tsx records this chain explicitly per route — you can cat it to see the result.

Limits

  • One layout.tsx per directory. Multiple would be ambiguous.
  • Layouts can't be async functions. Use a loader for data + read it via useLoaderData.
  • Error boundaries don't catch loader errors. Loader errors render the page's error.tsx; throwing inside the page's render does too. Both flow through the same boundary.
  • Layouts can call useLocation(), useParams(), useServerRequest(). They're React components like any other.