Skip to content

Router: Layouts and slots

The Phaze App (src/app.{tsx,phaze}) is the persistent root — it wraps every page and stays mounted across navigation. A Layout is an ordinary component the App (or a page) wraps content in for per-page chrome. Layouts use slots to expose more than one insertion point.

A named slot is just content the caller tags with a name (slot="header") so the component can drop it into a specific spot — as opposed to the default slot (children), which is the single untagged blob between the tags.

One component, three holes:

// CALLER fills the holes:
<Layout>
<Fragment slot="header"><Nav/></Fragment> {/* named slot "header" */}
<Fragment slot="footer"><Footer/></Fragment> {/* named slot "footer" */}
<PageContent/> {/* no slot= → the DEFAULT slot */}
</Layout>

phaze-compile (extractSlots) lifts each named slot to a top-level prop:

<Layout header={() => <Nav/>} footer={() => <Footer/>}>
{() => <PageContent/>} {/* the default child */}
</Layout>

So the Layout receives:

  • children<PageContent/> (the default slot)
  • header() => <Nav/> (the named slot “header”)
  • footer() => <Footer/> (the named slot “footer”)

…each a lazy Slot (() => JSXChild). The Layout reads them as ordinary props — type it inline so the slots are visible right in the signature:

import type { JSXChild } from '@madenowhere/phaze'
import type { Slot } from '@madenowhere/phaze-cloudflare'
function Layout({ children, header, footer }: {
children?: JSXChild
header?: Slot
footer?: Slot
}) {
return <>
{header} {/* "header" renders here */}
<main>{children}</main> {/* default slot renders here */}
{footer ?? <Footer/>} {/* "footer", or a fallback if not passed */}
</>
}

Reads are bare ({header}, not {header?.()}): the runtime renders a function child as a reactive dynamic child, so the slot honors the lazy contract and re-renders if its content reads signals.

The pieces:

  • The slot="X" attribute is the decider; <Fragment> is the carrier. slot="X" routes that content into the X prop; untagged children fall into the default children. Slots must be tagged on a <Fragment slot="X"> — phaze only extracts Fragments, so slot="X" on a plain element is not lifted (it stays an inline child). <Fragment> also groups multiple children under one slot name without a wrapper element.
  • <Fragment> is compile-only. extractSlots strips it, lifting its children into the lazy () => … prop and emitting a plain array for multi-child slots (() => [a, b]), never a runtime JSXFragment — so the Fragment symbol never reaches phaze.
  • Why bother — with only children (one default slot) a component has one place to inject caller content. Named slots let the caller fill multiple distinct holes and let the component control placement of each.

It’s the same idea as Astro’s <slot name="x"/> and Vue’s named slots, but phaze flattens slots to props: <Fragment slot="header"> becomes a header={() => …} prop — exactly the React shape <Layout header={…} footer={…}>{page}</Layout>, just with the <Fragment slot> authoring sugar and a lazy () => wrap (so a slot renders only if/when the component places it).

Section layouts — co-located layout.{tsx,phaze} OR export const layout

Section titled “Section layouts — co-located layout.{tsx,phaze} OR export const layout”

Two ways to attach a section layout to a page; both end at the same runtime contract.

Co-located layout.{tsx,jsx,phaze} (recommended for the common case) — drop a layout file next to a page / index file in the same folder; phaze pairs them automatically.

src/pages/about/
├── layout.tsx ← auto-pairs with about/page.tsx
└── page.tsx → /about (wrapped by AboutLayout)
src/pages/about/layout.tsx
export default function AboutLayout({ children }: { children?: JSXChild }) {
return <div class="about-shell"><nav>About sections</nav><main>{children}</main></div>
}
// src/pages/about/page.tsx — no import, no export const layout needed
export const head = { title: 'About' }
export default function About({ data }) { return <p></p> }

Same-folder pairing only — pages/about/layout.tsx wraps pages/about/page.tsx but NOT pages/about/team/page.tsx (sub-pages need their own layout file OR an explicit export const layout). Reference-equality persistence (below) handles the “shared chrome across sub-pages” case without ancestor cascade.

export const layout (override or programmatic) — the page module exports a layout reference explicitly; this wins over any co-located layout file.

// src/pages/about/team/page.tsx — sub-page shares chrome by re-exporting parent
import AboutLayout from '../layout'
export const layout = AboutLayout // ({ children }) => Node
export const head = { title: 'Team' }
export default function Team({ data }) { return <p></p> }

Persistence is reference-equality. The client tracks c(currentRoute()?.module.layout), which only fires when the layout component reference changes. Navigating /about → /about/team (both pointing at AboutLayout) keeps the layout mounted and swaps only the page inside it; navigating to a page with a different layout reference swaps the layout. SSR and prerender wrap the page in the layout in one pass.

A layout is an ordinary component (({ children }) => Node) and can use named slots like any other. It must return a real element — a fully-static, prop-less layout would hoist to a staticSubtree handle (give its root a binding, like any component). Persistence requires a Phaze App; without src/app.tsx the layout still applies but doesn’t persist (direct-mount mode). v1 is a single section layout per page; nested chains (root → section → sub-section) compose explicitly via the explicit export const layout re-export pattern shown above.

Parallel routes — @name/ URL-driven slots

Section titled “Parallel routes — @name/ URL-driven slots”

Named slots are caller-filled — a parent’s JSX hands content into {header}. Parallel routes are the URL-filled counterpart: a folder named @name/ tags its pages with slot: "name", so the URL decides what fills the layout’s name slot — alongside the main page, each with its own loader. It’s phaze’s take on Next’s @folder parallel routes; the difference is what each slot resolves to (see the aside).

A @name/ folder is URL-invisible — its segment strips from the path exactly like a (group)/ route group — but where a group is pure colocation, an @name/ folder also routes its pages into a named slot instead of children:

src/pages/legal/
├── layout.tsx ← places {children} AND {panel}
├── index.phaze → /legal (fills children)
├── privacy/page.phaze → /legal/privacy (fills children)
└── @panel/ ← URL-invisible; tags its pages slot: "panel"
├── page.phaze → /legal (fills the "panel" slot)
└── privacy/page.phaze → /legal/privacy (fills the "panel" slot)

/legal now matches two routes — the main index.phaze (→ children) and @panel/page.phaze (→ the panel slot) — and both render in one pass. Soft-navigate to /legal/privacy and the same panel hole swaps to the privacy panel while the layout (and its sidebar nav) stay mounted.

A slot page is an ordinary page: an optional loader (run in parallel with the main page’s) and a default export that receives { data }. A .phaze slot page needs its fence — a bare-JSX .phaze with no ---page … --- compiles to an empty component:

---page
head: { title: 'Legal' } // a slot page's head is IGNORED — the main page owns <head>
---
<aside class="panel">Legal questions? Email legal@neuralkit.ai</aside>

The layout receives each slot as its own prop, named after the folder (@panel/panel), typed Slot and placed bare — identical to a caller-filled named slot, and to Next’s team / analytics props:

src/pages/legal/layout.tsx
import type { JSXChild } from '@madenowhere/phaze'
import type { Slot } from '@madenowhere/phaze-cloudflare'
export default function LegalLayout({ children, panel }: {
children?: JSXChild
panel?: Slot
}) {
return <div class="legal-shell">
<nav>{/* … */}</nav>
{panel} {/* @panel/ fills this — swaps per URL; this layout never re-runs */}
<article>{children}</article>
</div>
}

The pieces:

  • @name/ is URL-invisible like (group)/. The segment strips from the URL; the difference is that a group is colocation only, while an @name/ folder additionally tags every page beneath it with slot: "name".
  • A main route and a slot route may share a URL. /photos/:id (main) and a @modal/photos/[id] slot route coexist — the matcher partitions main vs per-slot route sets before keying its pattern map, so the shared pattern doesn’t collide. matchRoutes(path) returns { main, slots }, and every matched slot runs its own loader in parallel with the page’s.
  • Slots thread to the App and the section layout — one flat namespace. A root pages/@modal/ fills modal (placed {modal}) in app.{tsx,phaze}; a pages/legal/@panel/ fills panel (placed {panel}) in legal/layout.tsx. Whichever component places {name} mounts it.
  • Only a slot’s loader + default export are used. Its head is ignored (the main page owns the document <head>), and per-slot section layouts aren’t applied in v1.

v1 scope. Parallel routes fill named layout slots from the URL, each with its own parallel loader and the reactive seam above. Deferred to v2: Next’s intercepting routes ((.)photos/[id] — the bookmarkable modal-over-context), per-slot history, and per-slot loading / error / default files.