Pages & dynamic segments

Filesystem → URL mapping, dynamic [id] segments, catch-all [...slug] queries, query params, and private files.

A page is any *.tsx file under src/pages/ that's not a special file (layout.tsx, error.tsx, loading.tsx, not-found.tsx) and doesn't start with _. Its default export is the page component; the URL comes from the file path.

A static page

// src/pages/about/page.tsx → /about
import type { ReactNode } from 'react'

export default function About(): ReactNode {
  return (
    <div className="max-w-2xl mx-auto py-12 px-6">
      <h1 className="text-3xl font-bold">About us</h1>
      <p>Voltro Cloud is a framework for shipping multi-tenant SaaS.</p>
    </div>
  )
}

That's it. Save the file, the CLI's discovery sees it on next save, the page is live at /about.

index files

index.tsx maps to the directory's URL:

src/pages/page.tsx         → /
src/pages/users/page.tsx   → /users
src/pages/admin/page.tsx   → /admin

Dynamic segments

Brackets in the filename are dynamic. The captured value comes through useParams<T>():

// src/pages/users/[id]/page.tsx → /users/:id
import { useParams } from '@voltro/web'

export default function User() {
  const { id } = useParams<{ id: string }>()
  return <h1>User {id}</h1>
}

Multiple dynamic segments in one path:

src/pages/orgs/[orgId]/projects/[projectId]/page.tsx
// → /orgs/:orgId/projects/:projectId

const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()

Catch-all queries

[...name] captures one OR more URL segments as a single param value (joined by /):

// src/pages/docs/[...slug]/page.tsx → /docs/<anything>
const { slug } = useParams<{ slug: string }>()
// /docs/intro/getting-started → slug = "intro/getting-started"

Optional catch-all (matches the base URL too):

// src/pages/docs/[[...slug]]/page.tsx
// /docs       → slug = ""
// /docs/foo   → slug = "foo"
// /docs/foo/bar → slug = "foo/bar"

Priority

When multiple files could match (static, dynamic, catch-all), priority is:

  1. Static segments win over dynamic.
  2. Dynamic single ([id]) wins over catch-all ([...slug]).
  3. Optional catch-all ([[...slug]]) wins over required catch-all ([...slug]) — the optional form scores as more specific, so it matches first.
src/pages/users/page.tsx     # /users → wins for /users
src/pages/users/[id]/page.tsx       # /users/:id → wins for /users/42
src/pages/users/new/page.tsx        # /users/new → wins (static beats dynamic)
src/pages/[...rest]/page.tsx        # everything else

Development mounts in React StrictMode

The client entry wraps the tree in StrictMode, so in development every effect runs twice, with a real unmount in between. That is the point — it surfaces effects that are not safe to re-run — but it has one consequence worth stating outright, because it is expensive to rediscover:

An effect that keys off "have I mounted before?" fires on the second mount. A deployment measured this as a picker that cleared its own just-loaded value: a "when the dependency changes, clear the selection" effect built on a mount-counting ref saw the second mount as a change, and an edit form opened with an empty required field and a red message while the record had the value. Visible only in development, which is exactly where it reads as a data bug.

The rule that survives the double mount: compare VALUES, not runs. A reset that fires because "this is not the first run" is a reset waiting for the next remount; one that fires because the dependency actually differs is not.

Query strings

Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a searchParams schema export, the same page-export convention as meta, loader, and renderMode:

// src/pages/search/page.tsx
import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  q:    Schema.optionalWith(Schema.String, { default: () => '' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
})

export default function SearchPage() {
  const { q, page } = useSearchParams(searchParams)   // q: string · page: number
  // …
}

useSearchParams(searchParams) — the page passes its own export — returns the decoded, typed shape, SSR-aware: the same call site reads the request URL on the server and window.location.search on the client. An invalid query string falls back to the schema's defaults instead of crashing the render, so every field must be optional or carry a default (Schema.optionalWith(..., { default }) — a schema that cannot decode an empty query throws at the first read, naming the fix). Links to the route type-check against the same schema via typed withQuery.

Two boundaries worth knowing:

  • renderMode: 'isr' + a searchParams export is refused at boot — the isr cache is keyed by path (plus tenant + locale), so the first query's variant would be cached and served for every other query. Use ssr, or drop the export and read the query client-side only. See Render modes.
  • renderMode: 'static' build renders see only the schema's defaults — a build has no query string. The client decodes the live query after hydration; a static page keyed off search params is a client-side concern.

Mirror routes share ONE schema

Bilingual apps with mirrored trees (pages/x/page.tsx + pages/[locale]/x/page.tsx) re-export the original page's schema instead of copying it:

// src/pages/[locale]/search/page.tsx
export { searchParams } from '../../search/page'

One schema, no drift — the mirror page decodes exactly what the original declares.

Two scanners read this line, and they do not agree. The isr refusal above is a source scan, and so is the route-builder codegen that brands a route's URL with its searchParams type — but they recognise different spellings, which is worth knowing before you pick one:

spelling on the mirror page typed withQuery on the mirror route isr + schema refused
export const searchParams = … yes yes
export { searchParams } from '../../search/page' no yes
export * from '../../search/page' no no
import { searchParams as base } … + export const searchParams = base yes yes

The middle two are the ones to watch. A clause re-export still decodes correctly at runtime and is still refused on isr — but the route builder does not see it, so withQuery on the mirror's URL falls back to untyped and nothing reports it. A star re-export is seen by neither: the binding is on the module at runtime (export * forwards every named export), so the page behaves as if it declared a schema while the isr refusal never fires.

So: prefer the last row when you want the mirror route's links type-checked, and never reach a schema through export * on an isr page.

// src/pages/[locale]/search/page.tsx — one schema, and both scanners see it
import { searchParams as base } from '../../search/page'

export const searchParams = base
export { default } from '../../search/page'

Routes without a schema

useSearchParams() without an argument stays the raw URLSearchParams — nothing changes for a route that declares no schema:

import { useSearchParams } from '@voltro/web'

const q = useSearchParams().get('q') ?? ''

Co-locating components, hooks and tests

Only page.tsx is a route. Everything else under src/pages/ is ordinary code and may sit next to the page that uses it:

src/pages/
├── users/
│   ├── page.tsx        # → /users
│   ├── page.test.tsx   # its test
│   ├── [id]/page.tsx         # → /users/:id
│   ├── UserCard.tsx          # a component — no URL
│   └── useFilters.ts         # a hook — no URL

No naming trick is needed to keep something out of the router: the absence of the suffix already does it. A _-prefixed directory has no special meaning — it is neither required nor recognised.

Before this convention, every .tsx under src/pages/ became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, voltro update renames your pages for you.

Two routes, one screen

When two URLs must render the same component — a versioned path kept alive because devices in the field are configured against it, say — re-export it instead of copying it:

// src/pages/v2/page.tsx → /v2, rendering exactly what / renders
export { default, renderMode } from '../page'

export { default } from '…' satisfies the page contract: the module has a default export, it just did not declare it here. Forwarding renderMode alongside it is what keeps the two routes from drifting apart — the build follows the forward when it computes the render profile, so /v2 is classified the same as /, not silently as static.

export { default as Screen } from '../page' is the opposite: it renames the default away, leaving this module without one. That still fails the contract.

Trailing slashes

The canonical form is no trailing slash — always link with <Link to="/about">, not <Link to="/about/">.

Link forwards every prop it does not consume itself to the underlying <a>, ref included — so it drops straight into a polymorphic slot (<Button component={Link} to={url}>) without a wrapper.

The framework does NOT emit a trailing-slash redirect on its own. If you need /about//about normalisation (for SEO), configure a 301 redirect at your reverse proxy.

What pages CAN'T do

  • Live outside src/pages/. Discovery walks one root. Helpers + components go elsewhere; pages go here.
  • Have multiple default exports. One page per file.
  • Be .ts files. Pages must be .tsx — React components only.
  • Be discovered via dynamic import. The CLI generates the import statements at boot; runtime adds need a re-discover (which voltro dev does on save).