Router: Pages
Page Anatomy
Section titled “Page Anatomy”Each file under src/pages/** is a route. A page module is just an ES module — every per-route behavior is a named export. The same shape applies whether the page is hard-loaded, soft-navigated by the router, prerendered, or SSR’d.
The page exports
Section titled “The page exports”Every per-route behavior is a named export; all are optional except default.
| Export | Type | Runs | What it controls |
|---|---|---|---|
default | ({ data }) => Node | client + server | The page component. Called with data = the loader’s return value. |
head | HeadFn | PageHead | server | <title>, description, raw <head> tags. Bare object for static; function form for per-request. |
loader | PageLoader | server | Page data — output flows into data prop and currentRoute().data. Async-friendly, full ctx. |
prerender | true | build-time | Bake this route to a static file — the page’s server exports run at build and write dist/client/<path>/index.html; the worker is never invoked for it. Auto-detected + unioned with cloudflare({ prerender: […] }). See Prerendering. |
revalidate | number | RevalidateSpec | build-time → response header | Per-page caching: maxAge, swr, tags. See Caching. |
headers | HeadersFn | server | Raw response headers — escape hatch when revalidate isn’t enough (Link preload hints, custom X-…). |
layout | ({ children }) => Node | server + client | Section layout — wraps the page and persists across same-section navs. See Layouts and slots. |
restricted | Restricted | server (pre-stream) | Route protection — evaluated by the guard before head/loader run. true / role / roles / predicate / options. See Authorization. |
sitemap | (ctx) => Row[] | build-time | Per-page dynamic-route sitemap loader — each row’s columns fill the route’s :params. See Sitemap. |
head, loader, and headers run in parallel on the server — adding a loader doesn’t serialize with head, and per-page caching headers don’t add TTFB unless they themselves await I/O. restricted runs before them (the pre-stream gate); sitemap runs only at build.
PageContext — what every server-side export receives
Section titled “PageContext — what every server-side export receives”interface PageContext<Bindings> { params: Record<string, string> // matched [id] / [...rest] segments env: Bindings // typed CF bindings — DB, KV, R2, DO, … request: Request // raw Request for header/body access ctx: ExecutionContext // CF execution context — waitUntil, passThroughOnException cookies: Cookies // per-request reader + writer (Set-Cookie queued)}Bindings is typed by your wrangler.toml via worker-configuration.d.ts (generated by wrangler types), so ctx.env.DB is autocompleted as your D1 binding.
A page using everything
Section titled “A page using everything”import type { PageLoader, HeadFn } from '@madenowhere/phaze-cloudflare'import ProductLayout from '../../../components/ProductLayout'
export const layout = ProductLayout
export const head: HeadFn = ({ params, env }) => env.DB.prepare('SELECT name FROM products WHERE id = ?1') .bind(params.id).first<{ name: string }>() .then(row => ({ title: row?.name ?? 'Product' }))
export const loader: PageLoader = async ({ params, env }) => env.DB.prepare('SELECT * FROM products WHERE id = ?1') .bind(params.id).first()
export const revalidate = { maxAge: 3600, swr: 86400, tags: ({ params }) => [`product:${params.id}`], // dynamic per-route tag}
export default function Product({ data }) { return <article>{/* … */}</article>}Same module shape across deployment modes:
- Hard load (cache miss):
head,loader,headers/revalidateresolve in parallel server-side, thendefaultrenders into the SSR’d HTML. - Soft nav (router): the same HTML response is fetched as text; the client extracts the page subtree +
__PHAZE_CF__payload. - Prerender: the same exports run at build time, written to
dist/client/<path>/index.html(with the limitations in Phaze + Cloudflare → Static prerendering).
Dynamic segments use [id] / [...rest] filenames; the matched values arrive as params on ctx and as currentRoute().params on the client.
Prerendering
Section titled “Prerendering”export const prerender = true (or prerender: true in a .phaze ---page fence) bakes the route to a static file — the page’s server exports run once at build, write dist/client/<path>/index.html, and Cloudflare’s static-assets handler serves it directly; the worker is never invoked for that route. The flag is auto-detected and unioned with the config list cloudflare({ prerender: […] }).
---pagehead: { title: 'Privacy' }prerender: true---The pass runs the real worker with real bindings, so loader D1/KV/R2 reads bake; it’s for deploy-pinned content (no live request — request.cf empty, “now” frozen), and it can’t also carry revalidate (the static asset never hits the worker the directive targets). rust:-backed fences bake when cloudflare({ rustApi }) spawns the Rust worker for the pass, and JSON direct-fence values seed so the hydrating client adopts them (FlatBuffers bake their text but refetch the binary — the seed/render split).
Full mechanics — Method C, remote: true, the collision rules, what does and doesn’t bake — live in Phaze + Cloudflare → Static prerendering.
File-system conventions
Section titled “File-system conventions”Phaze-cloudflare uses a strict basename allowlist — only specific filenames become routes; anything else under src/pages/ is a co-located helper, never publicly addressable. This eliminates the “every file is a route” risk of accidentally publishing internal-debug.ts, notes.tsx, or an unfinished OldHero.tsx.
Three directories carry meaning:
| Dir | Allowed | Becomes |
|---|---|---|
src/pages/ | index.{tsx,jsx,phaze} / page.{tsx,jsx,phaze} / layout.{tsx,jsx,phaze} / NNN.{tsx,jsx,phaze} (status code) / <name>.<content-ext>.ts | rendered pages + section layouts + status-code pages + content endpoints |
src/api/ | every .ts / .js (directory IS the opt-in) | HTTP handler at URL prefixed with /api/ |
src/actions/ | the conventional RPC handler module | RPC actions (see Actions) |
Anything else under src/pages/ is IGNORED by route discovery — safe to co-locate Hero.tsx, notes.tsx, _internal.ts, etc. next to their page.tsx without exposing them.
Pages, layouts, content endpoints
Section titled “Pages, layouts, content endpoints”File path under src/pages/ | URL |
|---|---|
index.{tsx,jsx,phaze} | / |
about/index.{tsx,jsx,phaze} | /about |
about/page.{tsx,jsx,phaze} | /about (alternative basename) |
about/layout.{tsx,jsx,phaze} | (NOT URL-addressable — paired with sibling page) |
users/[id]/page.{tsx,jsx,phaze} | /users/:id (dynamic segment — folder name) |
[...path]/page.{tsx,jsx,phaze} | /*path (catch-all) |
(marketing)/about/index.{tsx,jsx,phaze} | /about (group folder hidden) |
(app)/dashboard/[id]/page.{tsx,jsx,phaze} | /dashboard/:id |
legal/@panel/page.{tsx,jsx,phaze} | /legal (fills the panel slot — @panel/ folder hidden) |
@modal/photos/[id]/page.{tsx,jsx,phaze} | /photos/:id (fills the root modal slot) |
robots.txt.ts | /robots.txt (content endpoint) |
sitemap.xml.ts | /sitemap.xml (content endpoint) |
.well-known/security.txt.ts | /.well-known/security.txt |
blog/feed.xml.ts | /blog/feed.xml (content endpoints work at any depth) |
index and page are equivalent basenames. about/index.tsx and about/page.tsx produce the same /about URL. Both strip from URL computation — pick whichever reads better for your tree (index is the historical default; page colocates with siblings under the section’s own name).
Dynamic segments are FOLDER names, not file basenames. users/[id]/page.tsx is the routable form — users/[id].tsx (basename [id]) is NOT recognised under the allowlist. This makes co-location with layout.tsx natural — users/[id]/layout.tsx pairs with users/[id]/page.tsx.
Layouts auto-pair with their same-folder sibling page — about/layout.tsx wraps about/page.tsx automatically; no import ceremony in the page module. The layout is NOT a route on its own. See Layouts and slots for the full mechanism.
@name/ folders are parallel-route slots — URL-invisible like (group)/. The @name segment strips from the URL, but every page beneath it is tagged with that slot name and fills the layout’s individual name prop (placed {name}) instead of children. So legal/@panel/page.phaze and legal/index.phaze BOTH serve /legal — the @panel page renders into the layout’s {panel}, the index into {children}. A @name/ page can be dynamic (@modal/photos/[id]) and shares its URL with the main route at that pattern. Full mechanism, including the reactive per-slot island behaviour: Layouts and slots → Parallel routes.
Content endpoints use the basename pattern <name>.<content-ext>.ts — the basename’s nested extension is the discriminator. Recognised content extensions: txt, xml, json, png, ico, webmanifest, rss, atom, svg. robots.txt.ts routes; helpers.ts (no content-ext) is IGNORED. URL uses the full basename including the content-ext (/robots.txt, NOT /robots).
Two files mapping to the same URL is an error. Discovery throws at build time with both file paths named, so the collision is obvious and fast to fix.
Status-code pages — NNN.{tsx,phaze}
Section titled “Status-code pages — NNN.{tsx,phaze}”A page basename that’s an HTTP status code — 404, 403, 500, 401 — is a status-code page, not a route. It’s never URL-addressable (you can’t navigate to /404); instead the in-core() guard renders it in place, at the attempted URL, with the real HTTP status code.
File under src/pages/ | Rendered when |
|---|---|
404.{tsx,phaze} | no route matches the URL (HTTP 404) |
403.{tsx,phaze} | a signed-in user lacks the required role/rule (HTTP 403) |
500.{tsx,phaze} | an unhandled error during SSR (HTTP 500) |
401.{tsx,phaze} | unauthenticated and isomorphicAuth: true (HTTP 401, in place — see below) |
Status pages resolve nearest-up-the-tree: a route renders the closest status page at or above its folder, so admin/403.phaze overrides a root 403.phaze for everything under /admin. With no status page for a code, the guard returns a bare text response carrying that status. A status page is an ordinary page component (no special props); the basename is reserved, so it can never collide with a real route.
The 401 page’s redirect-vs-in-place behaviour (isomorphicAuth) is part of the auth flow — see Authorization → 401.
HTTP endpoints — src/api/
Section titled “HTTP endpoints — src/api/”For JSON APIs and webhooks, put .ts/.js handler files under src/api/. The directory IS the opt-in — every file there becomes a route. URLs are automatically prefixed with /api/ (the dir name is the URL prefix).
File path under src/api/ | URL |
|---|---|
users.ts | /api/users |
users/[id].ts | /api/users/:id |
webhooks/stripe.ts | /api/webhooks/stripe |
v1/index.ts | /api/v1 (index strips) |
Endpoints export GET / POST / PUT / DELETE / PATCH / HEAD / OPTIONS named handlers — each (ctx) => Response. See Endpoints.
The default src/api/ path is opt-in by convention — if the directory doesn’t exist, nothing is walked, no warning. Override the path with cloudflare({ api: 'src/handlers' }), or cloudflare({ api: false }) to disable entirely.
Route groups — (name)/
Section titled “Route groups — (name)/”A folder whose name matches /^\(.+\)$/ is a route group: its name disappears from the URL, but the folder still exists in the file tree. Groups are a colocation device — they let you organise related routes together without their grouping folder appearing as a URL segment.
src/pages/├── (marketing)/ ← invisible in URLs│ ├── about/page.tsx → /about│ ├── pricing/page.tsx → /pricing│ └── careers/page.tsx → /careers├── (app)/ ← invisible in URLs│ ├── dashboard/[id]/page.tsx → /dashboard/:id│ ├── settings/page.tsx → /settings│ └── account/page.tsx → /account└── index.tsx → /Groups can nest ((a)/(b)/about/page.tsx → /about), and they compose with dynamic segments and the page/index basenames as the table above shows.
Route groups do NOT cascade a layout. Phaze supports same-folder co-located layout.tsx (each folder’s layout pairs with its sibling page), but the layout does NOT auto-wrap descendant folders. In Next, app/(marketing)/layout.tsx auto-wraps every page in the (marketing)/ subtree; in phaze, each folder pairs locally and nested chains compose explicitly. Route groups themselves are URL hygiene only — (marketing)/ doesn’t get its own auto-wrapping layout. (Cascade is reserved as a future non-breaking opt-in.)
When a group needs shared chrome across sub-pages, pick whichever mechanism fits:
| Mechanism | Where it lives | Cost |
|---|---|---|
| App-level Layout | src/app.{tsx,phaze} already wraps every page; branch on currentRoute()?.pattern inside it for per-group chrome | Zero per-page declaration |
| Named slots | Each page renders <Fragment slot="header"> content | One slot prop per chrome hole |
Co-located layout.{tsx,phaze} | Dropped next to a page.{tsx,phaze} — auto-pairs with same-folder sibling, reference-equality persists across navs between same-layout pages | Zero per-page declaration (the file convention IS the wiring) |
Section layout — explicit export const layout | Each sub-page in the group imports the parent’s layout and exports const layout = AboutLayout; reference equality persists it across same-layout navs | One import + export const layout per sub-page |
| Plain composition | Each page imports the layout component and wraps its own return value | One import + wrap per page |
The trade-off: phaze gives you Astro’s explicit composition (no Next-style ancestor cascade) AND gets Next-style persistence for free from the signal router (reference-equality on currentRoute().module.layout). Co-located layout.tsx is convenient but local; for shared chrome across sub-pages, use the App-level Layout’s signal-branched chrome OR the explicit section-layout import pattern.
Extending (label)/ to non-routing dirs — pure organisation
Section titled “Extending (label)/ to non-routing dirs — pure organisation”Inside src/pages/, (group)/ is a routing semantic — the segment strips from URL computation. Outside src/pages/, parens in folder names carry no toolchain meaning — they’re ordinary characters in directory names. But the same dir-naming pattern doubles as a useful organisation convention for the rest of src/, especially src/components/.
The rule (decides where to put a file):
Plain folder — ui/, forms/ | (label)/ — (app)/, (landing)/, (experimental)/, (fabric)/ |
|---|---|
| Means: a subsystem with a coherent API | Means: a tag — files share SOME context, but no API surface |
| Test: “Could you point at this folder and say here’s the API for Y?” | Test: “Does this folder name help a reader spot which files share some context X?” |
Usually has a barrel index.ts | Doesn’t need a barrel — files are independent |
src/components/ui/Button.tsx | src/components/(landing)/Hero.tsx |
Mechanically there is zero difference. Both are just folders; both produce the same import paths with the segment included (from '../components/(app)/Layout'). The (label)/ form sorts to the top of the file tree in editors (parens precede letters in ASCII), so labelled groups are visually distinct without any tooling.
Three axes a (label)/ can carry
Section titled “Three axes a (label)/ can carry”A label tags ONE of three meanings — surface, phase, or dependency. Each axis answers a distinct reader question, and the label must be a noun-shaped thing, not a characteristic-shaped adjective.
| Axis | ✅ Good labels | ❌ Anti-examples | What it tags |
|---|---|---|---|
| Surface | (app), (landing), (dashboard), (auth) | (public), (visible) | Route/page where it ships |
| Phase | (experimental), (deprecated), (legacy) | (broken), (slow) | Lifecycle state |
| Dependency | (fabric), (stripe), (supabase), (turnstile) | (gpu), (3d), (payments), (database) | Library/integration it’s coupled to |
The principle that rules out the anti-examples: label what the file is COUPLED TO, not what it produces or how it behaves. A label must answer “if this thing changed, would these files need to move?” — (gpu) doesn’t (gpu isn’t a thing); (fabric) does (fabric is the library that would be swapped). Adjective-shaped labels ((heavy), (visual), (animated)) fail the same test.
Four discipline rules
Section titled “Four discipline rules”- Three valid axes (above) — surface, phase, dependency. Adjectives don’t qualify.
- A label earns its place when 2+ files would carry it. Single-file labels are noise. If only one file would go in
(fabric)/, leave it in its surface label or flat until a sibling appears. - Nesting is allowed; outer = more stable axis.
(landing)/(experimental)/NewHero.tsxreads as “landing surface, experimental phase.” Surface usually outranks phase outranks dependency for stability. Stop at two levels — three-level nesting ((app)/(fabric)/(experimental)/) signals one level isn’t earning its place. - Cross-group imports are a smell. If
(app)/Footer.tsxneeds to import from(landing)/X.tsx, the file’s been miscategorised — promote to a subsystem folder, refactor the consumer to take a slot, or leave the file flat atsrc/components/X.tsx.
Naming guidance — surface labels
Section titled “Naming guidance — surface labels”Name surface labels after the route/section, not a business concept. Next.js community lesson: business-category labels drift the moment scope moves.
- ✅
(landing)/names/— precise today, precise tomorrow - ✅
(dashboard)/names/dashboard/*— section-scoped - ❌
(marketing)/covers homepage AND about AND careers AND pricing — widens to noise - 🟡
(app)/is precise when it means the persistent app shell (Layout/Header/Footer/etc.); imprecise if it grows to mean “any in-app surface”
Worked-example layout
Section titled “Worked-example layout”A real consumer with these conventions applied end-to-end:
src/components/├── (app)/ ← surface label — persistent app shell, mounted in src/app.{tsx,phaze}│ ├── Layout.tsx│ ├── Header.tsx│ ├── Navigation.tsx│ └── Footer.tsx├── (landing)/ ← surface label — live on /│ ├── Hero.tsx│ ├── EarlyAccess.tsx│ ├── Card.phaze│ └── (experimental)/ ← nested phase label, if/when needed│ └── NewHero.tsx├── forms/ ← subsystem — form helpers, app-wide└── ui/ ← subsystem — design-system primitives ├── logos.tsx └── regmarks.tsxEvery file is either labelled (some context tag) or in a subsystem folder (cross-surface reusable with an API). Nothing floats unlabelled; the path itself tells you the lifecycle and risk.
Don’ts
Section titled “Don’ts”- ❌ Pre-emptive labelling. If
src/components/has 5 files and no clear axes yet, leave them flat. Reach for(label)/when 2+ files would share the label. - ❌ Putting a reusable in
(landing)/“because it’s used there first.” If a second surface ends up importing it, promote it to a subsystem folder before the cross-group imports pile up. - ❌ Characteristic-shaped labels (
(gpu),(3d),(heavy),(animated)). Label what the file is COUPLED to, not what it produces or how it behaves. - ❌ Conflating routing-side
(group)/with this org convention. Insidesrc/pages/, parens strip from URLs (toolchain). Outside, parens are file-tree signalling only (DX). - ❌ Adding a barrel
index.tsto(label)/. Barrels make sense for subsystems (a unified API surface); labels are just colocation, not an API. If a label dir starts wanting a barrel, it’s a sign it should be a subsystem folder. - ❌ Nesting more than two levels.
(app)/(fabric)/(experimental)/X.tsxis a sign one level isn’t earning its place.
Sitemap
Section titled “Sitemap”cloudflare({ site, sitemap }) generates dist/client/sitemap.xml at build, auto-discovered from the route table — no hand-written endpoint, no enumerating URLs. The file is excluded from the Worker, so Cloudflare’s static-assets handler serves it at zero per-request cost.
cloudflare({ site: 'https://example.com', // required — the origin for absolute URLs sitemap: true, // zero-config: one <url> per static page route})Static page routes are discovered automatically; slot (@name/) and endpoint routes are skipped, as is anything matching exclude. Dynamic routes (/blog/[slug]) enumerate their own instances via a per-page loader (below). An options object gives full control:
sitemap: { exclude: ['/admin/**', '/draft/**'], // globs — `**` spans segments, `*` one changefreq: 'weekly', priority: 0.7, lastmod: '2026-01-01', // site-wide defaults customPages: ['/landing', 'https://blog.example.com/x'], // extra URLs (site-relative OR absolute) i18n: { defaultLocale: 'en', locales: ['en', 'fr'] }, // emit <xhtml:link> hreflang alternates serialize: (e) => (e.url.endsWith('/private') ? undefined : e), // per-entry escape hatch (undefined drops)}changefreq / priority / lastmod are site-wide defaults; serialize overrides per entry; customPages adds URLs the route table can’t see (external pages). Past 45,000 URLs the output splits into a sitemap-index.xml linking sitemap-0.xml, sitemap-1.xml, … (the threshold @astrojs/sitemap uses).
Dynamic routes — a per-page sitemap loader
Section titled “Dynamic routes — a per-page sitemap loader”A dynamic route (/blog/[slug]) can’t be statically enumerated — its URLs come from data. Give the page a sitemap loader and the build runs it once with real bindings — the same Method C path prerendering uses, so env.DB / KV / R2 resolve against your local .wrangler/state (or the edge resource with remote: true):
---pagesitemap: env.DB.prepare('SELECT slug, updated FROM posts').all()---It’s a field-style ---page label — implicit pageCtx (env / params / request / cookies), no ({ env }) =>, exactly like a ---data field. (In a .tsx page write export const sitemap = ({ env }) => … — there’s no fence sugar there.)
Each returned row becomes a URL: its fields fill the route’s :param segments by column name (row.slug → :slug), and lastmod / updated becomes <lastmod>. A raw D1 .all() result works directly (phaze reads its .results). This is Astro’s static auto-discovery and Next’s enumerate-from-data, in one declaration — on the edge, with your real data. Dynamic URLs merge into the same sitemap.xml as the static ones.
Prefer code-first? Write a src/pages/sitemap.xml.ts content endpoint and don’t set sitemap — the config-generated static file would otherwise shadow the endpoint at /sitemap.xml.
Internationalization (i18n)
Section titled “Internationalization (i18n)”cloudflare({ i18n }) declares your locales; routes come from the file system, and phaze:i18n gives config-bound helpers — no astro:i18n-style negotiator dependency, the detection is ~12 lines of Accept-Language parsing baked into the runtime.
cloudflare({ i18n: { defaultLocale: 'en', locales: ['en', 'fr', 'de'], routing: { prefixDefaultLocale: false }, // default — `/about`, not `/en/about` },})Routes are folders — the same basename allowlist as the rest of the router, no special i18n routing layer. src/pages/fr/index.phaze → /fr, src/pages/fr/about/index.phaze → /fr/about; the default locale lives un-prefixed at the root (src/pages/about/index.phaze → /about) unless prefixDefaultLocale: true.
Helpers — phaze:i18n. One isomorphic module (server + client); the resolved config is baked in at build, so there’s nothing to thread:
import { getLocaleUrl, getLocaleUrlList, currentLocale, preferredLocale, locales } from 'phaze:i18n'
getLocaleUrl('fr', '/about') // → '/fr/about' — re-localises too: getLocaleUrl('fr', '/en/about') → '/fr/about'getLocaleUrl('en', '/about') // → '/about' — default un-prefixedcurrentLocale('/fr/about') // → 'fr' — leading segment if it's a known locale, else defaultLocalegetLocaleUrlList('/about') // → [{ locale:'en', url:'/about' }, { locale:'fr', url:'/fr/about' }, …] — switcherpreferredLocale(request) // → 'fr' — Accept-Language negotiation (server-side)preferredLocale negotiates the request’s Accept-Language against your locales (exact tag → primary subtag → defaultLocale) — the whole “edge-native detection” story in ~12 lines, no library, with request.cf (country / colo) right there if you want geo-routing on top. Call it from a loader or middleware to choose the locale for a request.
Sitemap is automatic. With i18n set, sitemap emits <xhtml:link hreflang> alternates for every locale — one source of locale truth, no second config.