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". (One exception: a modal.phaze basename makes the folder contribute its segment instead — see below.)
  • 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. A modal.phaze route is the exception that proves the rule: at the URL it owns, its exports become the synthesized main’s — the main still owns the head; the modal simply is the main there.

modal.phaze — a slot page that owns its URL

Section titled “modal.phaze — a slot page that owns its URL”

Every slot-bearing URL needs a main route — hard-load a slot-only URL and the server synthesizes the 404 page as the main, with the slot rendering over it. For a modal that historically meant inventing an empty sibling: a login/page.phaze whose whole body was <></>, existing only so /login had a main for the @login/ slot to mount over.

modal.phaze deletes that phantom route. The folder names the mount point; the basename names the role — and the modal role carries the routing fact with it:

src/pages/+dashboard/
├── layout.tsx ← places {children} AND {login}
├── index.phaze → /dashboard (main)
└── @login/
└── modal.phaze → /dashboard/login (fills "login" — and OWNS this URL)

Two things distinguish it from an ordinary slot page:

  • The @name/ folder contributes its segment instead of stripping. +dashboard/@login/modal.phaze serves at /dashboard/login — no sibling main exists or is needed.
  • When no main route matches its URL, the router synthesizes an empty one from the modal’s own exports. children render nothing — the layout draws its frame and the modal fills its slot — while restricted, head, layout, revalidate and every other page export come from the modal module. The modal is the URL’s authority: its restricted: false is what carves a public door out of a +name/ subtree, exactly as the empty sibling’s used to.

The authoring half is the ---modal fence — the modal-mode spelling of ---page, same labeled-statement region, and the basename alone already implies page mode (a loader-only modal needs no fence at all):

---modal
restricted: false // the door is public inside the + subtree
layout: DashboardLayout
---state
session: s.async(actions.Session.request(), { revalidate: minutes(10) })
---
<div class="fixed inset-0 …">…the modal card…</div>

layout: points at the same component reference the sibling pages resolve, so Object.is persistence holds across /dashboard ⇄ /dashboard/login — the chrome never re-mounts while the door opens and closes over it. A layout.{tsx,phaze} co-located inside the @name/ folder pairs automatically, like any page’s sibling layout.

Ordinary slot pages are untouched: an index/page basename under @name/ keeps stripping the folder from the URL and filling its slot at the subpath-derived URLs — the legal/@panel/** pattern above works exactly as before. modal is a role you opt into, valid only inside a @name/ folder; a modal.phaze elsewhere stays a co-located helper.

v1 scope. Parallel routes fill named layout slots from the URL, each with its own parallel loader and the reactive seam above. Modal-over-context ships today, in its canonical shape — an @name/modal.phaze that owns its URL — or the equivalent long-hand: a real main route sharing a layout.{tsx,phaze} with the slot placed there. Either way the layout’s instance survives the nav (Object.is on the component reference), so the modal mounts over chrome that keeps its DOM identity, and the URL is bookmarkable at 200. Deferred to v2: Next’s intercepting routes ((.)photos/[id] — the same URL rendering differently by arrival mode, modal on soft nav vs full page on hard load), per-slot history, and per-slot loading / error / default files.