Concepts
Rendering modes
Each route renders as static HTML, on the server per request, or as a client-routed SPA, with the same components, mixed freely in one app.
#Concept
| Mode | HTML | JS shipped | Navigation |
|---|---|---|---|
static (default) |
At build time, for each path from staticPaths() |
Islands only | MPA (prefetch, opt-in View Transitions) |
server |
Per request: the shell, then streamed boundaries | Islands only | MPA (prefetch, opt-in View Transitions) |
spa |
Server-rendered (per request, or at build time with prerender) and hydrated, or a client shell with ssr = false |
The whole subtree | Client router (<Link prefetch>) |
A route sets export const mode = "server" (a string literal); pages inherit the mode of their
nearest layout that sets one, else the project default set in vite.config.ts with
lumiose({ mode: "server" }) ("static" when unset). export const ssr = false applies to spa
routes: the build emits a static shell and the page renders in the browser. lumiose({ ssr: false })
makes that the default for every spa route, so lumiose({ mode: "spa", ssr: false }) turns the
whole project into a client-rendered SPA built as static files. export const prerender = true
renders an spa page (or, in a layout, every spa page below it) at build time instead of per
request, and lumiose({ prerender: true }) makes that the default for every spa page (see
Prerendered SPA routes).
#Choosing a mode
Pick the mode by what the page needs, per route, not once for the whole app:
| Mode | For | Typical pages | Costs | Sample app |
|---|---|---|---|---|
static |
Content that is the same for every visitor and known at build time | Docs, marketing, blog posts, catalogs | Rendered once per build. Any static host (a form action still needs a server for the POST). JS: islands only. |
ssg-travel-journal, this website |
server |
Pages that depend on the reader or the request, or must be fresh | Account pages, dashboards, ?q= search results, forms that re-render with errors |
Rendered on every request: a server or a serverless function. JS: islands only. | ssr-auth, ssr-bookshelf |
spa |
App-like areas (client navigation, layout state kept across pages) whose first view depends on the request | Signed-in app areas | Rendered on every request, then the whole subtree ships and hydrates. | spa-router-e2e (test app) |
spa + prerender |
App-like areas whose pages are the same for everyone and should work without JS and be indexed | Public app pages, music tools, galleries | Rendered once per build; any static host. The whole subtree ships and hydrates. | spa-chiptune, spa-dotmatrix |
spa + ssr = false |
Code that cannot run on the server, where an empty first view is acceptable | Editors, canvas or audio tools, admin screens behind a sign-in | A static shell; nothing renders until the JS has loaded. No action or headers exports: use endpoints. |
/csr in spa-router-e2e |
A rule of thumb, in order:
- Start with
static. - A page needs an interactive part: add an island (
client:*) to that part. That is not a reason to change the mode. - A page reads the reader's session, cookies or search params, or must be fresh:
server. - A whole area behaves like an app:
spa. Addprerenderwhen its pages are the same for every reader, and usessr = falseonly for code that cannot run on the server.
lumiose routes lists every route with its resolved mode (CLI).
#Granularity and combining
Modes are chosen per route, and the finer features per component:
- Project:
lumiose({ mode, ssr, prerender })sets the defaults. - Layout:
export const mode,ssrandprerenderin a layout apply to every page below it; the nearest layout that sets one wins. - Page: the page's own exports win. Endpoints (
route.ts) take a mode too:staticendpoints are written at build time and only takeGET(LUM5005). - Component: a
client:*directive makes one usage of a component an island;<Suspense>marks the part of a page that may arrive after the rest.
What combines with each mode:
| Mode | Islands (client:*) |
<Suspense> |
Request APIs (cookies(), locals(), url().searchParams) |
|---|---|---|---|
static |
Yes | Resolved at build time: no fallback, no streaming | No (LUM5001) |
server |
Yes | Streams after the shell | Yes |
spa |
Not needed: the whole subtree hydrates; a client:* inside renders as a plain component |
Streams after the shell, then hydrates | Not in SPA code, which also runs in the browser: call endpoints |
spa + prerender |
Not needed | Resolved at build time | No (LUM5001) |
spa + ssr = false |
Not needed | Shows its fallback, then renders in the browser | No: the page renders in the browser; call endpoints |
And across levels:
staticandserverpages share layouts. A layout renders with the page: at build time for astaticpage, per request for aserverpage.- An
spasubtree under server-rendered layouts. The SPA starts at the outermostspalayout of the page (or at the page). Layouts above it render on the server only (per request, or at build time withprerender) and never hydrate. - A page can leave an
spaarea. A page with its ownmode = "static"or"server"below anspalayout is not part of the SPA: its layouts render on the server only, and links to it are document navigations. - A
staticpage with a formaction. TheGETis the built file; thePOSTruns the action on the server and renders the page with its result. prerenderandssr = falseexclude each other, also when one comes from a layout or the project (LUM5006).
ssr-bookshelf mixes modes in
one app: /about and /genres are static with an account-menu island, the catalog and account
pages are server, and /api/me and /api/search are server endpoints.
#Close to partial prerendering
Partial prerendering (PPR, part of Cache Components in Next.js 16) builds a page's static shell
once, sends it at once on every request, and streams the request-dependent holes, each a
<Suspense> boundary, into the same response. Lumiose has no mode that serves a prebuilt shell
and fills its holes per request: a page is either built (static, spa with prerender) or
rendered on every request (server, spa). Two patterns get close:
server+<Suspense>. Everything outside the boundaries is the shell; it renders per request and is sent as soon as it resolves, and the slow or per-reader parts stream into the same response. No extra request and no client JS beyond the swap script. Trade-offs: the shell renders on every request (no CDN-cached shell), so keep it free of slow work. A streamed boundary is applied by a ~300 B$swapscript, or natively by browsers that apply<template for>(declarative partial updates); without either, the reader keeps seeing the fallback. So keep essential content and forms out of streamed boundaries. A boundary whose content renders synchronously is not streamed at all: it is folded into the shell.static+ islands. The page is built once and served by any static host or CDN; the per-reader parts are islands that call an endpoint after load (client:idleandfetchJSON("/api/me")). Trade-offs: those parts need JS and a second request, appear after hydration (give their fallback a stable size), and never appear without JS.
Good candidates: pages whose main content is the same for everyone, with small per-reader or fresh
parts (a product or book page with an account menu, a reading-list button and reviews), and slow,
optional widgets. Poor candidates: pages that are mostly per reader (use plain server), content
that decides the status or the head (it must be in the shell, LUM5004), and
forms that must work without JavaScript inside a hole whose data is slow.
The bookshelf sample shows each pattern:
/aboutand/genres:static+ the account menu, aclient:idleisland over/api/me./(home):server+ a<Suspense>around the recommendations, which really stream./books/[isbn]: shell + holes. The shell reads only the route param: the book's metadata and description. The header's account area, the reading-list toggle, "Your review", the rating summary and histogram, and the reviews list are each a<Suspense>hole that runs its own queries and readslocals()orurl()itself when it needs the reader or the query string, with a fallback of stable size. Their data is synchronous (node:sqlite), so every hole resolves inside the shell: nothing streams, and every form works without JavaScript. Async data in a hole would stream it, as on the home page. The split marks which parts a prebuilt shell could hold.
#Compared with Astro islands
Lumiose islands follow Astro's idea: pages are server HTML with no JS by default, and a client:*
directive at the usage site makes one component instance an island, with the same directive names
(client:load, client:idle, client:visible, client:media, client:only) and serializable
props. The differences (Astro 7.3, checked against its docs and source in October 2026):
| Topic | Astro | Lumiose |
|---|---|---|
| Components | .astro server components plus islands from React, Preact, Svelte, Vue or Solid, mixable on one page |
One component model on the server and in the browser, so client:only needs no framework name |
| Island markup | An <astro-island> custom element around each island, with its component URL and props as attributes |
Comment markers (…) plus JSON records: no wrapper element, so flex, grid and child selectors see the island's own elements |
| Directive options | client:idle={{timeout}}, client:visible={{rootMargin}} |
client:idle hydrates at the latest after 2 s; client:visible="200px" |
| Early events | Not replayed | Clicks, input, submits and key presses before hydration are replayed |
| Data | Passed to the island as props | Islands may also await data; the values are recorded during SSR and replayed during hydration, and cached awaits are sent once per page |
| Shared state | Nano Stores (client side) | Scoped state (defineState, Sharing state): one instance per page, seeded on the server and sent with the page |
| Deferred server parts | server:defer server islands: a script fetches each one in a separate request after load (encrypted props, a fallback slot); works on prerendered pages |
No server:defer. A server page streams the part in the same response with <Suspense> (above); a built page uses a client island over an endpoint |
| App navigation | <ClientRouter /> swaps documents on navigation, with transition:persist |
spa subtrees with a client router (lumiose/router) and layouts that persist; MPA pages use prefetch and, opt-in, native cross-document View Transitions |
#How pages render
Shell first, then stream (server mode).
- Shell. The response starts once everything outside
<Suspense>has resolved, including async components without a boundary. Until then you can still set the status, headers and cookies, callredirect(),notFound()orerror(), and add<Title>/<Meta>/<Head>. A failure renders theerrorpage with the right status. - Stream.
<Suspense>boundaries then stream in as they resolve, out of order, and replace their fallbacks (with native<template for>updates or a ~300 B$swapscript). An error renders the boundary's<ErrorBoundary>in place. A head or status change inside a streamed boundary isLUM5004. - SSG and prerender resolve everything: no fallbacks and no swap scripts.
So: put slow, optional content inside <Suspense> to send the page sooner; leave essential
content (and anything that decides the status) outside.
Hydration. Only islands and SPA subtrees hydrate. The compiled code has a single DOM walk;
importing lumiose/dom/hydrate (done by the island loader and SPA entries) makes it claim existing
server nodes instead of creating them, so bundles without hydration carry no hydration code.
Markers are comments, used only for blocks, branches, text separators and async ranges.
SPA mode. An spa subtree starts at the outermost spa layout of a page (or at the page
itself) and renders as a single root island: layouts above it render on the server only. Its props
come from serializable route data; request, cookies and locals are not passed in. SPA code
calls endpoints instead. Pages with an SPA load the SPA entry (the client route table, a lazy
chunk per SPA layout and page, and the island loader) and navigate with
lumiose/router. In a build, the page also modulepreloads the chunks of
its SPA layouts and page (and their imports), so a deep link fetches them alongside the entry
instead of after it has run. When nothing in a build hydrates (every SPA page is
ssr = false and the app has no islands), the SPA entry starts the router without the island
loader, and the build ships no hydration code.
ssr(the default): the server renders the island and the browser hydrates it, replaying the server's awaited data.ssr = false: the server sends a shell (the document, the layouts above the SPA and an empty island) without loading the SPA's modules, so they may use browser APIs at module scope; the browser renders the island. Such pages cannot exportactionorheaders: use endpoints.not-foundanderrorpages below anspalayout render inside the SPA too.
#Prerendered SPA routes
export const prerender = true on an spa page renders it in the build's prerender step, as a
static page is rendered: the build writes real HTML for it, which the SPA entry hydrates, and later
navigation is client-side as usual. Search engines, link previews and visitors without JavaScript
see the content, and a static host can serve the page.
Opt in per page, per layout or for the project.
export const prerender = trueon a page; on a layout, for everyspapage below it; orlumiose({ prerender: true })for everyspapage. It is inherited likemodeandssr: the page's own export wins, then the nearest layout that sets it, then the project default. A page opts out withexport const prerender = false, and is then rendered per request; a layout'sprerender = falseturns the default off for its subtree.prerendermust be a literaltrueorfalse. In an endpoint, or in a layout or page whose mode isstaticorserver, it has no effect (aLUM5002warning).// src/routes/layout.stsx: every page below is prerendered… export const mode = "spa"; export const prerender = true;// src/routes/account/page.stsx: …except this one, which reads cookies per request export const prerender = false;Params. A page with params lists its paths with
staticPaths(), as astaticpage does; without it the build stops. The build writes one HTML file per path. On a static host, any other URL gets404.html, which renders the rootnot-foundpage inside the SPA.Deep links to paths that were not prerendered. When
404.htmlloads at the URL of an SPA page with params (a song saved in the browser, a record created after the build), the router renders that page in the browser instead of thenot-foundpage. The page decides with the browser's data, andnotFound()brings thenot-foundpage back. A small inline script in404.htmlhides the document until the router has rendered, so the 404 does not flash (at most 8 s, should the SPA not start). A URL that matches no page keeps thenot-foundpage, with status 404, and so does every URL without JavaScript.lumiose devand a server build do the same when such a page callsnotFound()while it renders on the server, which has no browser data.ssr = falsepages with params behave the same way. This needs a rootnot-foundpage: without one,404.htmlis a plain error page without the SPA.No request. The page renders at build time, so request APIs (
cookies(),request(),locals(),response(),url().searchParams) stop the build withLUM5001. Read them in the browser, or call an endpoint.Browser-only code. The page, its
spalayouts and everything they import now load on the server at build time, which anssr = falsepage never did. Move browser APIs (window,document,localStorage,AudioContext,requestAnimationFrame) out of module scope and out of component setup: read them inonMount(), an effect or an event handler, which run only in the browser. Create browser objects lazily, on first use. The compiler warns about browser globals at module scope (LUM4004).Not with
ssr = false.ssr = falserenders the page only in the browser. A page whoseprerender = true(its own or a layout's) meetsssr = false(its own, a layout's or the project's) isLUM5006: give a browser-only page under a prerendering layoutexport const prerender = false.lumiose({ prerender: true, ssr: false })is rejected. Underlumiose({ prerender: true })alone, a page withssr = falsestays a shell.
To move a client-rendered SPA (ssr = false) to prerendering: remove ssr = false, make its
modules safe to load on the server (above), add staticPaths() to pages with params, then set
prerender = true on the SPA's layout (or lumiose({ prerender: true })). lumiose dev renders the pages per request,
fully resolved as the build does, so hydration problems show up there first.
#Build
lumiose build runs: 1. client build (islands, SPA, CSS, assets, public/), 2. server
build, 3. prerender of every static route and every spa page with prerender (each
staticPaths() entry for routes with params) and every static GET endpoint, in-process through the built handler and fully resolved (no fallbacks
or swap scripts), 4. precompression: a .br and a .gz copy next to every text file of
dist/client of at least 1 kB, which @lumiose/adapter-node (and most static hosts and CDNs) serve
to browsers that accept them. Output:
dist/client: assets and prerendered files. Assets include those only server-rendered code imports (import photo from "./photo.png"in a route, a relative<img src>in a.mdimport), written once underassets/with the fingerprinted URL the HTML uses. Prerendered files arepath/index.html(path.htmlwithlumiose({ trailingSlash: "never" }), prerenderedspapages included), endpoint files such assitemap.xml, the shell of eachssr = falsepage without params, and404.htmlfrom the rootnot-foundpage when the root scope is static orspa. In anssr = falseSPA,404.htmlis the shell: a static host that serves it for unknown URLs gets deep links to every route. Vite'sbaseapplies to URLs, not to file paths.dist/server/entry.js:export default { fetch(request, platform?) }, which serves the static files ofdist/client, then the app (server build), pluscreateHandler({ env })(the app alone) andconfig. The server output bundles your server code, linked workspace packages and the Lumiose runtime (npm dependencies stay external, as Vite does for SSR), minified with function and class names kept, so production stack traces still name the functions.
A route (static, or spa with prerender) that reads the request while it prerenders (cookies(), locals(), request(),
response(), url().searchParams) stops the build with LUM5001; a static
endpoint can only export GET (LUM5005). Middleware still runs. A form
post to a prerendered page reaches its action on the server. Serve the build with
@lumiose/adapter-node (createServer({ port }): dist/client with caching headers, then the
handler) or lumiose preview, run dist/server/entry.js with Bun or Deno, or deploy it to
Cloudflare, Vercel or Netlify with an adapter (Deploy). A site whose every route is prerendered (no server route, no spa
route rendered per request, no form action; ssr = false routes with params need a 404.html
shell) builds no dist/server: deploy dist/client to any static host, and lumiose preview serves
it without the adapter. Set response headers once with lumiose({ headers })
(@lumiose/vite).
Status:
lumiose dev,lumiose build, prerendering,@lumiose/adapter-node, CSS links in built pages, the island loader (hydration), SPA mode (one root island,ssr = falseshells, prerendered pages) and the SPA router are available.
#API
| API | Use |
|---|---|
export const mode = "static" | "server" | "spa" |
The route's mode (inherited from layouts). |
export const ssr = false |
spa routes: render in the browser only. |
export const prerender = true |
spa pages: render at build time into HTML that hydrates (inherited from layouts). |
lumiose({ mode, ssr, prerender }) in vite.config.ts |
The project defaults for routes and layouts that do not set them. |
export async function staticPaths() |
static routes and prerendered spa pages with params: [{ params: { slug: "a" } }, …]. |
export function headers(ctx) |
Response headers for the page. |
<Suspense fallback> |
Marks content that may stream after the shell. |
renderToStream(root, options?) |
The server renderer behind it all; see reference: server. |
#Example
// src/routes/stats/page.stsx: the shell (title, heading) is sent first; the slow chart streams in
import { Suspense, Title } from "lumiose";
export const mode = "server";
async function SlowChart() {
const points = await new Promise<number[]>((resolve) => setTimeout(() => resolve([3, 1, 4]), 500));
return <p>Points: {points.join(", ")}</p>;
}
export default function Stats() {
return (
<main>
<Title>Stats</Title>
<h1>Stats</h1>
<Suspense fallback={<p>Loading chart…</p>}>
<SlowChart />
</Suspense>
</main>
);
}// src/routes/docs/[slug]/page.tsx: prerendered for each slug
import type { PageProps } from "lumiose";
const pages = { intro: "Introduction", setup: "Setup" } as const;
export async function staticPaths() {
return Object.keys(pages).map((slug) => ({ params: { slug } }));
}
export default function Doc({ params }: PageProps<"/docs/[slug]">) {
return <h1>{pages[params.slug as keyof typeof pages]}</h1>;
}// src/routes/tracks/[id]/page.stsx: prerendered for each id, then hydrated into the SPA
import { onMount, signal } from "lumiose";
import { params } from "lumiose/router";
export const mode = "spa"; // or from a layout, or lumiose({ mode: "spa" })
export const prerender = true;
export function staticPaths() {
return ["1", "2", "3"].map((id) => ({ params: { id } }));
}
export default function Track() {
const volume = signal(1);
// Browser-only: runs after hydration, never at build time.
onMount(() => volume.set(Number(localStorage.getItem("volume") ?? 1)));
return <h1>Track {params().id} (volume {volume()})</h1>;
}#Common mistakes
- Request APIs in a
staticroute.cookies(),request(),locals()and actions needmode = "server"(LUM5001). - Setting status or
<Title>inside<Suspense>. It arrives after the shell was sent (LUM5004). Decide them outside the boundary. - Wrapping the whole page in
<Suspense>. The shell is then just the fallback, and the status cannot change. Wrap only slow, optional parts. - Expecting
sparoutes to receivecookiesorlocalsas props. They are not serializable into the SPA (LUM4002); call an endpoint. - Computing
modeat runtime.modemust be a string literal (LUM5002). - Browser APIs at module scope or in component setup of a prerendered
spapage. The build renders it on the server; read them inonMount()or an event handler (LUM4004). prerender = trueon anssr = falsepage. The page cannot be both (LUM5006), also when one of them comes from a layout.
#Related diagnostics
- LUM4002: non-serializable island prop.
- LUM5001: request API used during prerender.
- LUM5002: invalid route file.
- LUM5004: head or status change inside a streamed boundary.
- LUM5006: prerendered page that renders only in the browser.
- LUM4006: hydration mismatch.
See also: Islands, Async components, Head and document.