Layout
- Category: Points
A layout is a point that wraps a set of pages in a shared shell — a header, a sidebar, a frame. It can own part of the route those pages sit under, load its own data with its own loading and error states, and hand that data down to the pages inside it. When you navigate between pages in the same layout, the layout stays mounted: it does not re-render and its data is not re-fetched.
import { root } from '@/lib/root'
import { NavLink } from '@/lib/navigation'
export const generalLayout = root.lets.layout(({ children }) => (
<div className="app">
<header>
<NavLink route="home">Home</NavLink>
<NavLink route="ideaList">Browse Ideas</NavLink>
</header>
<main>{children}</main>
</div>
))Every page built off generalLayout now renders inside that shell, in place of
{children}. The rest of this page shows where each piece comes from.
Declaring a layout
A layout is opened off a parent — the root, a base, or another
layout — and closed with .layout(component):
export const generalLayout = root.lets.layout(({ children }) => (
<div className="app">{children}</div>
)).layout is server-ssr-and-client: cut from the SERVER bundle when
ssr: false (or after a .clientOnly() earlier in the chain) — body and
imports removed from the server build; kept in the client build always, and in
the server build only when SSR is on.
The name comes from the variable — see points for the .lets
notation.
The component argument is optional. Omit it and the layout renders just its
children (({ children }) => children) — still useful for prefixing a route,
loading shared data, or acting as a provider without any visible
shell:
export const dataLayout = root.lets.layout('/app').layout()
// no shell, but it owns /app and can carry loaders/queries for its pagesOwning part of the route
Pass a route to the layout and the pages inside it inherit it as a prefix. A
layout that owns /ideas/:id with a page at / resolves the page to
/ideas/:id:
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.layout(/* ... */)
export const ideaViewPage = ideaLayout.lets
.page('/') // final route: /ideas/:id
.page(/* ... */)The route argument is optional. Omit it and the layout keeps its parent's route
(or / if there's none) — that's the case for generalLayout above, a shell
with no route of its own.
A layout's route can't contain a wildcard:
root.lets.layout('/files/*') // throws: Wildcard is not allowed in layout pointYou never need one. A layout doesn't try to match a span of URLs — the pages
decide which layout they belong to by building off it (or attaching it with
.layout(...)), and a page is free to carry its own wildcard. The layout just
contributes its optional route prefix to those pages; matching the actual URL is
the page's job.
Getting data into a layout
A layout loads data the same way a page does — with its own
.loader or by injecting a query with .with. The
layout component receives that data in data:
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.with(ideaQuery, ({ params: { id } }) => ({ id: Number(id) }))
.layout(({ children, data: { idea } }) => (
<div>
<h2>{idea.title}</h2>
{children}
</div>
))Strip categories here: .loader is server-only — cut from the
client bundle: its body and the imports it uses are removed, so it never ships
to the browser (it runs on the server). .with is
server-ssr-and-client — cut from the server bundle when ssr: false (or
after a .clientOnly()); kept in the client build always, and in the server
build only when SSR is on. Either way it ships to the browser, so put secrets in
the loader/.ctx, not in a .with mapper. A layout that closes with a loader
is also a query, and that self-query is finite by default; close with
.infiniteQuery after the loader to make it infinite instead.
A layout owns its loading and error states, gating its whole subtree:
- While the layout's data loads, the nearest loading component up the chain renders in place of the layout — the pages inside don't render yet.
- If the layout's loader throws, the nearest error component replaces the whole subtree, and the page inside is never rendered.
Because of this gate, by the time a page inside the layout renders, the layout's data is guaranteed to be loaded. That's what makes reading it from the page safe (next section).
You can target the layout's own states specifically with .layoutLoading(...)
and .layoutError(...), leaving the generic .loading / .error for
everything else. See Loading & error.
A layout is also a provider
A layout publishes its loaded data to the pages beneath it, like a
provider. The page reads it — no prop drilling, no re-fetch.
export const ideaViewPage = ideaLayout.lets
.page('/')
// read the idea straight from the layout, like from a provider
.with(() => ({ idea: ideaLayout.useValue('idea') }))
.head(({ props: { idea } }) => idea.title)
.page(({ props: { idea } }) => <h1>{idea.title}</h1>).useValue() is a React hook for use inside the layout's subtree:
ideaLayout.useValue() // => the whole data object
ideaLayout.useValue('idea') // => one key
ideaLayout.useValue(['idea', 'author']) // => a picked objectThe page does not inherit the layout's queries or loaders — it reads the
already-loaded value through the provider. Calling .useValue() outside a
mounted, loaded layout throws (useValue must be used within a Provider); but
because the layout gates its subtree, a page inside it is always past that
point.
Outside React, read the value imperatively:
ideaLayout.getValue() // => data; throws if not yet loaded
ideaLayout.getValueOrUndefined() // => data | undefined; never throwsgetValue throws if the value isn't set yet, so call it only from code that
runs after the layout has mounted and loaded; getValueOrUndefined is the safe
variant when you can't guarantee that.
Nesting layouts
A layout can be built off another layout. The child inherits the parent's route prefix and shell — the parent wraps the child, which wraps the page:
export const generalLayout = root.lets.layout(/* ... */)
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id') // route extends the parent's
.layout(/* ... */)
export const ideaViewPage = ideaLayout.lets.page('/').page(/* ... */)
// rendered as: generalLayout > ideaLayout > ideaViewPage, route /ideas/:idYou can also attach a layout to a page from the page's own code, instead of building the page off the layout:
export const homePage = root.lets
.page('/home')
.layout(generalLayout)
.page(/* ... */)When a page declares a layout this way, the layout's route params must be
compatible with the page's, or you get a compile error
(Layout params not compatible to current page params).
No re-render between sibling pages
Navigating between two pages in the same layout keeps the layout mounted — only the inner page swaps. The layout doesn't re-render, and its loader or query is not re-run.
// /ideas/1 (info) -> /ideas/1 (news)
// generalLayout — stays mounted
// ideaLayout — stays mounted, `idea` not re-fetched
// <page> — this is the only part that swapsThis is why a layout is the right place for a shell, a nav bar, or shared data that several pages read: it loads once and survives navigation within its subtree.
Gating a layout's subtree
A layout is a natural place to enforce access for every page beneath it. The
layout's loader body, .ctx, secrets, and DB calls are cut from the client
bundle by the compiler — body and the imports they pull in are removed, so they
never reach the browser — but the shell you render is shipped to the browser, so
put the gate in the data flow, not in the markup:
- Gate with a
.withwrapper (or a plugin), not by relying on.ctxalone. A layout's.ctxruns only when the layout has a loader — a loader-less layout makes no server request, so its.ctxnever runs.
.ctx is server-only — cut from the client bundle: its body and
imports are removed, so it never reaches the browser. .use and
.with are server-and-client — not cut from either bundle, kept in
both (isomorphic): a plugin or wrapper ships to both bundles, so the gate must
do its real check in code that the loader runs, not in markup that reaches the
browser.
import { authPlugin } from '@/lib/auth' // a plugin that resolves the user into props.me
import { ErrorPoint0 } from '@point0/core'
export const adminLayout = root.lets
.layout('/admin')
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new ErrorPoint0('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.layout(({ children, props: { me } }) => (
<div>
<span>{me.name}</span>
{children}
</div>
))me has to come from somewhere — here a plugin puts it in props
first. Returning an error from .with short-circuits to the error component:
ErrorPoint0 is the built-in error class, but you can use your own — see
error handling. The gate now covers every page under the
layout.
Reference
Component props
The layout component receives one object:
| Prop | Type | When |
|---|---|---|
children | the page (or nested layout) to render inside | always — render it to mount the subtree |
data | mapper output, or the layout's loaded data | always ({} if none) |
queries | tuple of injected query results | always ([] if none) |
props | props contributed by .with wrappers | always ({} if none) |
params | parsed route params | when the route has params / .params |
search | parsed query string | when .search is set |
setSearch | update the URL query (replace / patch / drop) | always (client-only; SSR no-op) |
location | the current location (location.params, …) | always (a layout's location may be an ancestor location) |
LoadingComponent | the resolved loading component | always |
ErrorComponent | the resolved error component ({ error }) | always |
A layout's location can be an ancestor location, not just the exact current
route — unlike a page, whose location always matches the page's route exactly.
Provider accessors (after .layout() closes it)
Available once the layout carries a server loader or a suitable query:
| Accessor | Returns |
|---|---|
useValue(keys?) | data (React hook); keys picks a subset; throws outside a mounted, loaded layout |
getValue(input?) | data; throws if not yet set |
getValueOrUndefined(input?) | data | undefined; never throws |
The closed layout point also exposes .route (a callable route0
route), .Layout / .X (the bound component), .Infer, and .lets to keep
building child points.
Methods that apply to a layout
Data & context: .loader, .clientLoader, .ctx,
.with, .mapper, .query /
.infiniteQuery, .relatedQuery,
.params / .search, .headers, .cookies.
UI: .head, .loading, .error,
.layoutLoading, .layoutError, .wrapper. .wrapper wraps even
the loading and error states (it renders outside the data gate).
Shared: .use (plugins), .middleware, .on /
.serverOn / .clientOn (events), .tag, .description,
.queryOptions / .layoutQueryOptions.
A layout also exposes the page-level defaults .pageError, .pageLoading,
.pageQueryOptions, .pageDehydratedStateQueryOptions and the prefetch family
(.prefetchPageOnNavigate, .prefetchPageOnLinkHover, .prefetchPagePolicy,
.onPrefetchPage). Like a base, a layout broadcasts these to its
subtree: each becomes the default for every page built under it (a page's own
setting still wins). The scroll defaults (.scrollPosition / .scrollRestore)
broadcast the same way. See Navigation and SSR for the
prefetch policies.
Where each of these runs (strip categories):
- server-only — cut from the client bundle: body and the imports it uses are
removed, so it never ships to the browser (it runs on the server):
.loader,.ctx,.headers,.cookies,.middleware,.serverOn,.serverOnPrefetchPage,.description..params/.searchare isomorphic on a layout (server-and-client) — they're server-only only on an action. - client-only — cut from the server bundle: body and its imports removed (it
runs only in the browser):
.clientLoader,.clientOn,.clientOnPrefetchPage,.prefetchPageOnNavigate,.prefetchPageOnLinkHover,.prefetchPagePolicy. - server-and-client — not cut from either bundle, kept in both (isomorphic):
the
.layoutcloser's query closers.query/.infiniteQuery/.relatedQuery,.onPrefetchPage(it runs on the client during prefetch and on the server before the first render),.use,.params/.search,.tag,.on, and the option setters.queryOptions/.layoutQueryOptions/.pageQueryOptions/.pageDehydratedStateQueryOptions. - server-ssr-and-client — cut from the SERVER bundle when
ssr: false(or after a.clientOnly()): body and imports removed from the server build; kept in the client build always, and in the server build only when SSR is on: the.layoutcomponent,.head,.loading/.layoutLoading/.pageLoading,.error/.layoutError/.pageError,.wrapper,.withmapper output,.mapper.
.relatedQuery adds its query to the layout's queries array just
like a .with(query) result — the difference is prefetch: a related query is
statically discoverable, so it's self-fetched without rendering under the cheap
policies (serverQuery / clientQuery / serverAndClientQuery), whereas a
.with(query) is only found by rendering and is prefetched only under the
expensive pageDehydratedState* policy.
.scrollRestore / .scrollPosition are client-only — cut from the server
bundle: body and their imports removed — and documented in full on the
navigation page — see there rather than here.
.head on a layout sets the document <head> while the layout is mounted, and
because the layout wraps its pages, that head applies across the subtree. A
page's own .head stacks on top. .head('global', …) additionally broadcasts
to every child's render.
A layout's .wrappers wrap the layout's own render only — they don't carry to
the pages built under it, and don't need to: a page renders inside the layout,
so it already sits within those wrappers.
Wrapping the 404 page in a layout
When no route matches, the router renders a built-in "Page Not Found". By
default it has no shell — but you usually want the not-found screen to keep your
header and frame. Pass layout404 to wrap it in one or more layouts, and
Page404 to replace the default screen itself. Both are options on
createNavigation (and props you can override on
<RouterRoutes>):
// src/lib/navigation.ts
import { generalLayout } from '@/lib/layouts'
import { NotFoundPage } from '@/components/NotFoundPage'
export const { Router, RouterRoutes /* … */ } = createNavigation({
routes,
hook,
Page404: <NotFoundPage />,
layout404: generalLayout, // or an array to nest several layouts around it
})layout404 takes a layout point or its name, or an array of them (rendered
outermost-first). See Navigation for the full option list.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️