Skip to content
HomePagesHomePages template kit

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.

  • A workspace — Install creates one, whose template.ts already declares a single page.
  • A section to put on the new page — Add a second template adds one.

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 entry
export 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.

Add the section that carries the chrome, by copying one that already works:

Terminal window
cp -r templates/starter/sections/main templates/starter/sections/header

Then list that one handle on both pages:

// template.ts — `header` on both pages: one instance, rendered on each
export 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.

A page the end user may hide takes { optional: true } as page()’s fourth argument:

// template.ts — the gallery is the user's to hide
export 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.

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.

One rewritten const in template.ts, and the menu in the shared section’s Renderer.tsx:

// template.ts — replacing the scaffolded one-page `pages` const
export 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 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.

  • template-invalid — the site map is rejected: two pages sharing an id or a path, no page at "/" or more than one, the "/" page marked optional, 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.
  • template.ts — the full composition contract, and what nav.anchors and nav.pages resolve 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.