Every page picks
how it renders.
Render mode is a per-page decision, not a project-wide one. A marketing page ships as static HTML with no JavaScript, the dashboard beside it hydrates fully, and a pricing page revalidates on a timer — same router, same loaders, one export line apart.
// src/pages/pricing/page.tsx — static HTML, zero JS on the wire
export const renderMode = 'static'
export const meta = ({ locale }) => ({ title: t(locale, 'pricing.title') })
// src/pages/app/page.tsx — same router, fully interactive
export const renderMode = 'spa'
// src/pages/blog/[slug]/page.tsx — pre-rendered, then revalidated
export const renderMode = 'isr'
export const revalidate = 600The router, concretely.
A file is a route
A page.tsx is a page, a layout.tsx wraps everything below it, and a [param] directory captures a segment. No route table to keep in sync with the filesystem, and nothing to register — moving a file moves the URL.
Five render modes, one export
static (pre-rendered at build), ssr (per request), isr (pre-rendered then revalidated), spa (client-only), islands (static HTML with interactive pockets) — and partial prerendering on isr: a cached anonymous shell with per-request holes streamed behind it, subject data never entering the cache. Change a page by exporting a different renderMode; the loader, the data and the components do not move.
Zero JS where a page declares none
A page that opts out of interactivity ships HTML and nothing else — the zero-JS mode strips every script and preload from the output. Islands make interactivity explicit AND cheap: an islands page ships its own slim entry — react plus the island runtime plus its islands, not the app bundle — measured at under a third of a fully hydrated page's first load, with the rest of the page staying server-rendered HTML that never re-renders.
Loaders that are typed end to end
A page loader runs on the server and its return type flows into the component — no manual generic, no cast. It can call your api through the same typed procedures the client uses, so first paint and subsequent navigation read the same contract.
Metadata that knows the locale
Title, description, canonical, OG tags and JSON-LD are computed per route with the active locale in hand, so a translated page gets translated head tags at build time — the version a search engine actually indexes. And the og:image itself is generated: declare it as a JSX template and static pages bake the PNG at build time while ssr pages serve it on demand over a signed, cached route.
Navigation without the boilerplate
Client-side navigation, a navigation indicator, scroll restoration, prefetch-on-intent and opt-in view transitions (the browser's cross-fade, styled with plain CSS) are wired in. Anchor clicks are intercepted where it helps and left alone where it does not, so an external link stays an external link.
Modal-with-URL (intercepting routes)
One page export — intercept: { from: '/photos' } — and a soft navigation opens the detail as an overlay above the still-mounted list (typed filters, scroll and state untouched), while a reload or shared link renders the same URL standalone. Back closes it, a dirty form's blocker now holds even the Back gesture, and each tree keeps its own query string. Parallel @slot routes are a declared non-goal: split panes are components in a layout, not a routing concept.
Why the mode is per page.
One app is rarely one workload
Most frameworks make you choose a rendering strategy for the project and then fight it at the edges — a static-site generator gets a dashboard bolted on, or a server-rendered app pre-renders marketing pages through a plugin. Real products are both at once. Making the mode a per-page export means the marketing page keeps its zero-JS payload on the same deploy as the fully interactive app, with no second toolchain and no second mental model.
A file is a route. Four of them.
// src/pages/pricing/page.tsx → /pricing
export default function Pricing() {
return <PricingTable />
}The render mode is one export line, and it is the line worth knowing: the same page can be static, server-rendered or client-only without changing anything else about it. You do not discover at launch that the marketing site and the app need two toolchains.
The router, in depth.
How does file-based routing work here?
A `page.tsx` is a route, a `layout.tsx` wraps everything beneath it, and a `[param]` directory captures a URL segment. There is no route table to keep in step with the filesystem and nothing to register — moving a file moves the URL, and deleting one deletes the route.
Route parameters are inferred from the path, so a page that reads a parameter its route does not declare does not compile. That closes the gap where a refactor renames a segment and the component keeps reading the old name until somebody clicks the right link. The query string gets the same treatment: a page declares a search-param schema once, reads come back decoded with defaults applied, and links to the route type-check their params against it.
Layouts nest, so shared chrome is declared once at the level it applies to rather than imported into every page below it — and a layout can load data of its own, which is what keeps a sidebar from refetching on every navigation.
Why is the render mode a per-page decision?
Because one application is rarely one workload. A marketing page wants pre-rendered HTML with no JavaScript; the dashboard beside it wants full interactivity; a pricing page wants pre-rendering with periodic revalidation. Forcing one strategy on all three means fighting it at two of them.
Here it is an export. Change `renderMode` and the loader, the data and the components stay exactly where they are — which makes the choice cheap to revisit when a page turns out to be more or less dynamic than you assumed.
The payoff is concrete: a zero-JS page ships HTML and nothing else — no hydration bundle, no framework runtime on the wire — while the interactive app in the same deploy behaves like a normal SPA. Islands sit in between: the page ships a slim per-page entry (react + its islands, no app bundle), the marked widgets hydrate, the rest stays static HTML.
How do loaders and metadata work?
A loader runs on the server and its return type flows into the component — no manual generic, no cast. It can call your api through the same typed procedures the client uses, so the data on first paint and the data after navigation come from one contract rather than two.
Metadata is computed per route with the active locale in hand, so titles, descriptions, canonicals, OG tags and JSON-LD are correct in the HTML a crawler reads — including the translated variants. That is the half of SEO a client-side library structurally cannot do.
For a page that subscribes, the loader drains the query to its first snapshot through a one-shot path with the same auth middleware, and the live subscription takes over after hydration. First render and live updates are the same query, not a duplicated fetch.
What do I not have to build?
Client-side navigation, a navigation indicator, scroll restoration, prefetch on intent, opt-in view transitions with a reduced-motion fallback, anchor interception that leaves external links alone, modal-with-URL intercepting routes — and a build-time image pipeline: one import suffix turns a static asset into AVIF/WebP srcSet variants with inferred dimensions and a blur placeholder. Each is small; together they are the difference between a router and a project.
Development gets route-level hot updates rather than a full reload on every edit, so component state survives while you iterate on a page — a property that quietly decides how pleasant an afternoon of UI work is.
And the boundary is enforced rather than trusted: everything the codegen pulls into the browser bundle must be browser-safe, and a boot-time guard aborts with the exact import chain if a server-only module would be dragged in by a descriptor's transitive imports.
The render modes, and when each fits.
| Mode | Use it when |
|---|---|
| static | The page can be built ahead of time — pre-rendered HTML, with a zero-JS opt-out that ships no script at all. |
| islands | Mostly static with interactive pockets — the page ships its own slim entry (react + its islands, not the app bundle) and only the marked islands hydrate. |
| ssr | The response depends on the request: personalised, authenticated, or freshly computed per visit. |
| isr | Pre-rendered then revalidated three ways: on a timer, by change-data-capture when a watched table moves, or on demand — revalidatePath/revalidateTable/revalidateTag from server code reach every replica, CDC or not. Add ppr = true and the cached shell gains per-request holes: deferred fields streamed onto the same response, fail-closed against credentials in the shell. |
| spa | State lives in the browser and a server-rendered first paint would be discarded on hydration. |
| Loaders | Server-side data whose return type flows into the component; drains a subscription for first paint. |
Frequently asked questions
Can one project mix static and interactive pages?
Yes — that is the point of making the mode a per-page export. A zero-JS marketing page and a fully interactive dashboard ship in the same deploy, from one router, with one build and one mental model.
Do static pages really ship no JavaScript?
When the page opts out of interactivity, yes: the zero-JS mode strips every script and preload, leaving pure HTML. `islands` keeps the page static HTML, hydrates only the marked widgets, and ships a slim per-page entry instead of the app bundle — measured at under a third of a full page's first-load JavaScript.
How do loaders get their types?
From their own return value — the type flows into the component with no generic to write and no cast. When a loader calls your api it goes through the same typed procedures the client uses, so a signature change breaks the loader too.
Is SEO metadata handled for translated pages?
Yes. Metadata is computed per route with the active locale, so a translated page has translated head tags, canonicals and structured data in the HTML a crawler reads — not injected after hydration.
What stops server code ending up in the browser bundle?
A boot-time guard that walks the generated import graph and aborts with the exact chain when a descriptor transitively reaches a server-only module. The trap is transitive — a shared helper that also imports the database handle — so it is enforced structurally rather than by review.
What routing connects to.
Open the framework. See it for yourself.
Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.