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 → /adminDynamic 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:
- Static segments win over dynamic.
- Dynamic single (
[id]) wins over catch-all ([...slug]). - 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 elseDevelopment 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'+ asearchParamsexport 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. Usessr, 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 URLNo 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
.tsxundersrc/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 updaterenames 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
.tsfiles. 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 devdoes on save).
Where to read next
- Layouts & route groups — wrap pages in shared chrome
- Loaders & meta — server-side data +
<head>tags - Render modes —
static/ssr/isr