Menu

Guides

View as Markdown

Layouts

Layouts are first-class route chrome. They are not a filesystem convention and they are not a client router feature. A layout is a declared value that composes around a page's children, and routes opt into it explicitly.

Declare a layout#

tsx
// Source: examples/crm/src/app-shell.ts
export const AppShell = layout({
  render: (_queries, _state, { children }) => (
    <main class="app-shell">
      <Sidebar />
      <section>{children}</section>
    </main>
  ),
});

export const dealsRoute = route('/deals', { layout: AppShell, page: () => <DealsPage /> });

The route still renders a full document. The layout is page chrome inside that document; the request shell owns the outer document template, loader, query scripts, and error shells.

Nest layouts with parent#

Use parent when a route segment adds chrome inside a broader shell:

tsx
// Source: examples/crm/src/app-shell.ts
const AccountLayout = app.layout({
  access: [app.authenticated],
  parent: AppShell,
  queries: { viewer: viewerQuery },
  render: ({ viewer }, _state, { children }) => (
    <section>
      <h1>{viewer.name}</h1>
      {children}
    </section>
  ),
});

const accountSettings = app.route('/account/settings', {
  access: [app.authenticated],
  layout: AccountLayout,
  page: () => <SettingsPage />,
});

export default app.assemble({
  layouts: [AppShell, AccountLayout],
  queries: [viewerQuery],
  routes: [accountSettings],
});

Use app.layout() when access depends on the app's inferred request shape. Layouts may declare queries, access, stylesheets, and per-segment boundaries. Guards refine the request before the layout renders, just like route and mutation guards. Layout queries are normal queries: they appear in kovo explain page, carry update plans, and observe the same cache and guard rules as page queries.

Run it#

Build the app, then ask the CLI for the resolved chain:

sh
kovo build ./src/app.ts
kovo explain page /account/settings --layouts dist/.kovo/graph.json

If you want the CLI's path-discovery rules, see Check current source and inspect artifacts explicitly.

Render segment failures#

Use boundaries when a route segment needs its own 404, 403, or error body instead of the app-level shell:

tsx
// Source: examples/commerce/src/app.tsx
const AccountLayout = layout({
  boundaries: {
    unauthorized: ({ status }) => <AccountDenied status={status} />,
  },
  render: (_queries, _state, { children }) => <AccountShell>{children}</AccountShell>,
});

export const invoiceRoute = route('/account/invoices/:id', {
  layout: AccountLayout,
  boundaries: {
    notFound: ({ request }) => <MissingInvoice user={request.session.user.id} />,
    error: ({ error }) => <InvoiceError error={error} />,
  },
  page: ({ params }) => <InvoicePage id={params.id} />,
});

Resolution is nearest-first: the route boundary wins, then the route's layout, then each parent layout, then the app shell. Boundary renderers receive { error, request, status }; error is only present for the error boundary.

Add parallel regions#

Use route-level regions when a layout needs sibling areas such as a docs page plus a sidebar rail. The layout decides placement; the route decides what each named region renders.

tsx
import type { RoutePageResult } from '@kovojs/server';

type DocsRegions = Readonly<{
  page: RoutePageResult;
  sidebar: RoutePageResult;
}>;

const DocsLayout = layout<unknown, {}, RoutePageResult, DocsRegions>({
  render: (_queries, _state, { regions }) => (
    <DocsShell page={regions.page} sidebar={regions.sidebar} />
  ),
});

export const guideRoute = route('/guides/:slug', {
  layout: DocsLayout,
  regions: {
    page: ({ params }) => <GuidePage slug={params.slug} />,
    sidebar: ({ params }) => <DocsSidebar activeSlug={params.slug} />,
  },
});

regions.page is the route leaf region. Additional names are scoped to that route/layout contract. The framework renders every region from the target full document and owns any navigation metadata it needs for enhanced navigation, so app TSX stays ordinary JSX.

No persistent layout state#

In v1, every navigation is still a full document GET. Enhanced navigation may preserve unchanged compiler-stamped layout segments as an optimization, but app authors do not author persistence policy and should not put route-lifetime assumptions in layout-local state. If chrome state must survive reloads and links, put it in the URL or in server query truth. If it is purely local, treat it like any other island state and keep server-refreshable boundaries in mind; a stateful island inside a refreshable target is rejected by the boundary checker.

Explain the resolved chain#

Use the layouts mode when a route's chrome gets hard to reason about:

sh
kovo explain page /account/settings --layouts graph.json

The output is narrower than that today: it prints the resolved layout chain, each layout's queries, the navigation segments, and the route leaf. For example:

kovo-explain/v1
PAGE /admin
prefetch: false
modulepreloads: -
stylesheets: -
queries: -
layouts: AppLayout
layout: AppLayout queries=viewer
navigation-segments: layout:AppLayout,page:/admin
segment: layout id=layout:AppLayout name=AppLayout queries=viewer components=-
segment: page id=page:/admin name=page queries=- components=-

That still makes shared chrome reviewable in CI instead of discovered by clicking around.

Next#

Spec & diagnostics

First-class layouts, route-level regions, nesting with parent, layout queries, guard, boundaries, stylesheets, and kovo explain page --layouts: SPEC §4.5 and §6.4. Documents are owned by the request shell: SPEC §9.5. Navigation is full-document first; layout persistence is not an app-authored v1 contract: SPEC §8. KV420 stateful-island boundary: SPEC §4.5 and §9.1.

API reference: @kovojs/server.