Add a second page
Give a template a second page — its own path and sections, with the header shared across both and a menu linking them.
Give a template more than one page: a second URL a visitor navigates to, carrying its own sections, with the header shared across both and a menu linking them.
Prerequisites
Section titled “Prerequisites”- A workspace — Install creates one, whose
template.tsalready declares a single page. - A section to put on the new page — Add a second template adds one.
Declare the page
Section titled “Declare the page”A page is a label, a path, and the sections on it. The scaffolded template.ts already
declares one — the site’s root — so a second page is one more entry in the same
composePages call:
// template.ts — the `pages` const, with a second entryexport const pages = composePages({ home: page("Home", "/", [sections.main]), gallery: page("Gallery", "/gallery", [sections.gallery]),});The object key is the page id, and object order is page order — the order a menu lists the pages in, and the order the editor shows their tabs. Within a page, array order is render order, exactly as it already was.
Every page declares a non-empty label. It is what a menu renders and what the editor puts on the page’s tab, so write it for a visitor: there is no default derived from the id, and a single-page template declares one too.
Exactly one page takes the path "/". A site has to serve its root, so that page is
mandatory. Every other path is one lowercase-kebab segment — "/gallery",
"/the-neighborhood" — served as a directory, so the published URL is /gallery/.
Nothing about the sections themselves changes. composeSections still builds one flat
pool of instances with no page in it, and page() is what lays them out.
Share the header across both pages
Section titled “Share the header across both pages”Add the section that carries the chrome, by copying one that already works:
cp -r templates/starter/sections/main templates/starter/sections/headerThen list that one handle on both pages:
// template.ts — `header` on both pages: one instance, rendered on eachexport const pages = composePages({ home: page("Home", "/", [sections.header, sections.main]), gallery: page("Gallery", "/gallery", [sections.header, sections.gallery]),});Listing the same handle twice is one instance, not two. It holds one set of content and one identity in the editor, and an edit to it lands on every page that lists it — which is what makes it chrome rather than a section that happens to be duplicated. Two different handles built off the same schema are two independent instances with separate content, exactly as composing a section twice on one page already is. That is the whole mechanism: there is no chrome or layout primitive beyond it.
Let the user switch a page off
Section titled “Let the user switch a page off”A page the end user may hide takes { optional: true } as page()’s fourth argument:
// template.ts — the gallery is the user's to hideexport const pages = composePages({ home: page("Home", "/", [sections.header, sections.main]), gallery: page("Gallery", "/gallery", [sections.header, sections.gallery], { optional: true }),});A hidden page publishes nothing and drops out of the menu, but it stays editable, and its
content is written at generation either way — so a user switching it back on gets a filled
page rather than an empty one. Leave optional off and the page always publishes; the
"/" page may never carry it.
Link the pages
Section titled “Link the pages”A cross-page link is the same line as a same-page one. A renderer reads
nav.anchors[targetKey] and gets back whatever reaches that section from where it is
being rendered: a bare #anchor when the target sits on this page, that page’s URL plus
the fragment (/gallery/#gallery) when it sits on another. Nothing in the renderer
branches on which.
// Renderer.tsx — the same line whether the target is on this page or another one<a href={nav.anchors.gallery} className="underline"> See the gallery</a>To build a menu, read nav.pages — the site’s pages in composition order, each carrying
its id, label, href and a current flag, with any switched-off page already
excluded. The shared header is where it belongs:
// Renderer.tsx — a menu in the shared section<nav aria-label="Pages" className="flex gap-4"> {nav.pages.map((link) => ( <a key={link.id} href={link.href} aria-current={link.current ? "page" : undefined}> {link.label} </a> ))}</nav>href is absolute and already carries the mount it was rendered under, so the same markup
serves the published site and the dev harness. A standalone section render — the
playground’s section route, check’s render — has no site map to enumerate, so
nav.pages is empty there and the menu renders no items.
Complete diff
Section titled “Complete diff”One rewritten const in template.ts, and the menu in the shared section’s
Renderer.tsx:
// template.ts — replacing the scaffolded one-page `pages` constexport const pages = composePages({ home: page("Home", "/", [sections.header, sections.main]), gallery: page("Gallery", "/gallery", [sections.header, sections.gallery], { optional: true }),});// Renderer.tsx — in the shared section's markup<nav aria-label="Pages" className="flex gap-4"> {nav.pages.map((link) => ( <a key={link.id} href={link.href} aria-current={link.current ? "page" : undefined}> {link.label} </a> ))}</nav>Verify
Section titled “Verify”Verify with the author loop, with one addition to its preview
step: dev serves every declared page — the home page at /template/<template> and each
other one at /template/<template>/<page-path> — so click the menu and confirm each page
carries the sections you listed on it, header included. check runs the nav contract per
page, so a link into a page that does not exist, or to an anchor that page does not carry,
fails there rather than shipping dead.
Rules that can fire
Section titled “Rules that can fire”template-invalid— the site map is rejected: two pages sharing an id or a path, no page at"/"or more than one, the"/"page markedoptional, a path that is not a single lowercase-kebab segment, a page carrying no sections, one instance listed twice on the same page, or a link naming a page or an anchor that does not exist.
See also
Section titled “See also”template.ts— the full composition contract, and whatnav.anchorsandnav.pagesresolve to.- Add a second template — scaffolding the section a new page carries.
- Preview with
dev— every page at its own URL, and the canvas mirror’s canvas bar, one tab per page for a website template.