Skip to content

Router: Pages

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.

Every per-route behavior is a named export; all are optional except default.

ExportTypeRunsWhat it controls
default({ data }) => Nodeclient + serverThe page component. Called with data = the loader’s return value.
headHeadFn | PageHeadserver<title>, description, raw <head> tags. Bare object for static; function form for per-request.
loaderPageLoaderserverPage data — output flows into data prop and currentRoute().data. Async-friendly, full ctx.
prerendertruebuild-timeBake 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.
revalidatenumber | RevalidateSpecbuild-time → response headerPer-page caching: maxAge, swr, tags. See Caching.
headersHeadersFnserverRaw response headers — escape hatch when revalidate isn’t enough (Link preload hints, custom X-…).
layout({ children }) => Nodeserver + clientSection layout — wraps the page and persists across same-section navs. See Layouts and slots.
restrictedRestrictedserver (pre-stream)Route protection — evaluated by the guard before head/loader run. true / role / roles / predicate / options. See Authorization.
sitemap(ctx) => Row[]build-timePer-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.

src/pages/products/[id]/index.tsx
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/revalidate resolve in parallel server-side, then default renders 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.

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: […] }).

---page
head: { 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.

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:

DirAllowedBecomes
src/pages/index.{tsx,jsx,phaze} / page.{tsx,jsx,phaze} / layout.{tsx,jsx,phaze} / NNN.{tsx,jsx,phaze} (status code) / <name>.<content-ext>.tsrendered 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 moduleRPC 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.

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 pageabout/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.

A page basename that’s an HTTP status code404, 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.

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.

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:

MechanismWhere it livesCost
App-level Layoutsrc/app.{tsx,phaze} already wraps every page; branch on currentRoute()?.pattern inside it for per-group chromeZero per-page declaration
Named slotsEach page renders <Fragment slot="header"> contentOne 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 pagesZero per-page declaration (the file convention IS the wiring)
Section layout — explicit export const layoutEach sub-page in the group imports the parent’s layout and exports const layout = AboutLayout; reference equality persists it across same-layout navsOne import + export const layout per sub-page
Plain compositionEach page imports the layout component and wraps its own return valueOne 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 APIMeans: 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.tsDoesn’t need a barrel — files are independent
src/components/ui/Button.tsxsrc/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.

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-examplesWhat 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.

  1. Three valid axes (above) — surface, phase, dependency. Adjectives don’t qualify.
  2. 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.
  3. Nesting is allowed; outer = more stable axis. (landing)/(experimental)/NewHero.tsx reads 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.
  4. Cross-group imports are a smell. If (app)/Footer.tsx needs 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 at src/components/X.tsx.

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”

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.tsx

Every 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.

  • ❌ 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. Inside src/pages/, parens strip from URLs (toolchain). Outside, parens are file-tree signalling only (DX).
  • ❌ Adding a barrel index.ts to (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.tsx is a sign one level isn’t earning its place.

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.

vite.config.ts
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):

src/pages/blog/[slug]/page.phaze
---page
sitemap: 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.

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.

vite.config.ts
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-prefixed
currentLocale('/fr/about') // → 'fr' — leading segment if it's a known locale, else defaultLocale
getLocaleUrlList('/about') // → [{ locale:'en', url:'/about' }, { locale:'fr', url:'/fr/about' }, …] — switcher
preferredLocale(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.