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'sindex.htmlshell owns those. Rendering them inside React puts them under#root+ the browser unwraps them, breaking the document structure. - Set
<html class>viatheme:inapp.config.ts. Bakes the dark/light class into the shell before first paint. - Set
<title>+ meta via the page'smetaexport. 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/settingsFor /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 # /settingsMarketing 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
/aboutto/dashboardunmounts 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.tsxonly 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 passerrorFallback/notFoundcomponents 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:
- Find the matching leaf —
dashboard/settings.tsx. - Walk up the directory tree, collecting every directory's special files in order.
- Build the chain: outermost layout → next layout → … → leaf page.
- Wrap each layer's
error.tsxas a React Error Boundary around the next layer. - Render.
The generated .framework/app.tsx records this chain explicitly per route — you can cat it to see the result.
Limits
- One
layout.tsxper directory. Multiple would be ambiguous. - Layouts can't be async functions. Use a
loaderfor data + read it viauseLoaderData. - 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.
Where to read next
- Render modes — how
static/ssr/isrinteract with layouts - Loaders & meta — server-side data + page-level
<head>tags