This is the dedicated reference for @pyreon/zero's server-side rendering. For the general Zero overview (routing, components, middleware, theme), see Zero. For pre-rendering pages at build time, see SSG. Two adjacent server-rendering features are documented on the Zero page: Server Islands (cacheable pages with per-request server-rendered holes) and Server Loaders (.server.ts data loaders that never ship to the client).
When mode: "ssr" or mode: "isr" is set, vite build runs the normal client build, then ssrPlugin's closeBundle hook spins up a programmatic Vite SSR sub-build of either your src/entry-server.ts (if it exists) or a synthetic entry, and writes dist/server/entry-server.js. The configured adapter's build({ kind: 'ssr', … }) is invoked so platform adapters (vercel / cloudflare / netlify) can wrap the bundle into a deployable serverless function.
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero/server'
export default {
plugins: [pyreon(), zero({ mode: 'ssr' })],
}When to use SSR vs SSG / SPA / ISR
| Mode | Render timing | Use for |
|---|---|---|
| SSR | Every request, on the server | Per-request personalization, fresh data, auth-gated pages |
| SSG | Build time → static HTML | Content that rarely changes — see SSG |
| SPA | In the browser only | Apps behind a login where SEO / first-paint HTML doesn't matter |
| ISR (runtime) | Cached SSR with stale-while-revalidate | High-traffic pages that tolerate brief staleness — see below |
Per-route override: any route file may export const renderMode = 'ssg' / 'ssr' / 'spa' / 'isr' to opt a single route in or out independent of the global mode.
Setup
The minimum:
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero/server'
export default {
plugins: [pyreon(), zero({ mode: 'ssr' })],
}Build → run:
$ vite build
[zero:ssr] Built dist/server/entry-server.js [adapter: node] (synthetic entry)
$ node dist/server/entry-server.js
# or via the adapter's deploy path — see Deployment belowThe build emits two trees:
dist/— the client bundle (HTML shell + JS / CSS assets / static files)dist/server/entry-server.js— the SSR handler
A platform adapter takes both as input and produces whatever the platform expects (Vercel function, Cloudflare worker, Netlify edge function, etc.).
The synthetic-vs-user-entry contract
The framework looks for src/entry-server.ts and uses that file as the SSR bundle entry when present. Use this when you need to:
ship custom middleware (
securityHeaders(),cacheMiddleware(),varyEncoding())override
ssr.mode: 'stream'configure
actions: { corsOrigins }wrap
createServer({...})in a request-logging or tracing decorator
import { routes } from 'virtual:zero/routes'
import { routeMiddleware } from 'virtual:zero/route-middleware'
import { apiRoutes } from 'virtual:zero/api-routes'
import { createServer } from '@pyreon/zero/server'
import { securityHeaders } from './middleware/security-headers'
export default createServer({
routes,
routeMiddleware,
apiRoutes,
middleware: [securityHeaders()],
})If no src/entry-server.ts exists, the framework materializes the canonical 6-line shape automatically:
// synthetic entry — created by ssrPlugin, removed after build
import { routes } from 'virtual:zero/routes'
import { routeMiddleware } from 'virtual:zero/route-middleware'
import { apiRoutes } from 'virtual:zero/api-routes'
import { createServer } from '@pyreon/zero/server'
export default createServer({ routes, routeMiddleware, apiRoutes })The synthetic entry is cleaned up after the build completes. A user-authored src/entry-server.ts is never removed — the cleanup discipline only deletes files the plugin created.
Loaders
Route files export a loader that runs at SSR time before rendering. The result is serialized into the HTML, then hydrated on the client so the first-render UI has the data without a fetch waterfall.
import type { LoaderContext } from '@pyreon/zero/server'
export const loader = async ({ params, request }: LoaderContext) => {
const post = await fetch(`https://api.example.com/posts/${params.id}`)
return { post: await post.json() }
}
export default function PostPage() {
const { post } = useLoaderData<typeof loader>()
return <article><h1>{post.title}</h1><p>{post.body}</p></article>
}LoaderContext shape
params: Record<string, string>— URL params from the route patternquery: URLSearchParams— parsed query stringrequest: Request— the original WebRequest. Read cookies / Authorization headers here for auth-gated content. Only populated during SSR —undefinedon every client-side navigation.redirect(url, status?)— throw to redirect (default 307, method-preserving)notFound()— throw to trigger the 404 boundary
Per-request auth
export const loader = async ({ request, redirect }: LoaderContext) => {
const session = request?.headers.get('cookie')?.match(/session=([^;]+)/)?.[1]
if (!session) throw redirect('/login')
return { user: await fetchUser(session) }
}Loader-thrown redirects
The SSR pipeline catches redirect() BEFORE rendering — no layout HTML is sent to the client. The handler returns a real HTTP 302 / 307 response with a Location: header. Server-side, this is the only way to redirect without leaking authenticated UI structure to anonymous users.
ISR (Incremental Static Regeneration)
mode: 'isr' combines SSR with stale-while-revalidate in-memory caching at request time. Pages are served from cache and regenerated in the background after the revalidation window.
defineConfig({
mode: 'isr',
isr: {
revalidate: 60, // seconds before a cached entry is considered stale
maxEntries: 1000, // LRU cap on the in-memory cache (default 1000)
// cacheKey defaults to `url.pathname + url.search`
},
})Cache key — two trade-offs
The default cache key is url.pathname + url.search. Query strings vary the cache (the /posts?id=42 vs ?id=99 shape is correctly distinct). Cookies and Authorization headers are NOT included by default — auth-gated content still requires an explicit cacheKey.
Auth-gated content (loader reads cookie / Authorization): the default is unsafe — the first user's cached HTML serves every other user. Supply a cacheKey that varies on the session identifier:
isr: {
revalidate: 60,
cacheKey: (req) => {
const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
return `${new URL(req.url).pathname}::${session}`
},
}High-cardinality query params (analytics tokens like utm_*, fbclid, gclid): the default causes cache explosion (one entry per click variant). For routes that ignore query strings entirely, strip them:
isr: {
revalidate: 60,
cacheKey: (req) => new URL(req.url).pathname,
}A one-time dev-mode warning fires at handler init when no cacheKey is configured — it names both fixes inline. Production builds tree-shake the warning to zero bytes via the standard process.env.NODE_ENV !== 'production' gate.
Pluggable backing store
The default in-memory Map is per-process. Multi-instance deploys (load-balanced Node, autoscaled containers, edge functions) need a SHARED store:
import type { ISRStore } from '@pyreon/zero/server'
import Redis from 'ioredis'
const redis = new Redis()
const store: ISRStore = {
async get(key) {
const v = await redis.get(`isr:${key}`)
return v ? JSON.parse(v) : undefined
},
async set(key, entry) {
await redis.set(`isr:${key}`, JSON.stringify(entry), 'EX', 86400)
},
async delete(key) {
await redis.del(`isr:${key}`)
},
}
defineConfig({ mode: 'isr', isr: { revalidate: 60, store } })The handler awaits every store call — the in-memory default stays cheap (no Promise allocation per request), while a Redis store hits real network promises naturally. When store is set, maxEntries is ignored (custom store owns its eviction policy).
Tag-based invalidation + filesystem store
isr.tagsForRequest(req) => string[] records tags on each cache entry at cache-set time; isrHandler.revalidateTag(tag) then drops every entry carrying the tag — the webhook-ergonomic unit ("a post changed → drop every page that rendered posts") without enumerating concrete paths:
defineConfig({
mode: 'isr',
isr: {
revalidate: 60,
tagsForRequest: (req) => (new URL(req.url).pathname.startsWith('/blog') ? ['posts'] : []),
},
})
// webhook handler:
await isrHandler.revalidateTag('posts') // → { dropped: n }createFsStore(dir) (from @pyreon/zero/server) persists entries AND the tag index to disk, surviving restarts — the right default for single-box node/bun deploys. Multi-instance deploys still want a shared Redis/KV store; custom stores must implement setTags/keysByTag for revalidateTag to work (both shipped stores — createMemoryStore, createFsStore — do). See Zero → ISR tag-based invalidation for the fuller configuration surface.
Build-time ISR
Build-time ISR (per-route export const revalidate = 60 + platform-driven rebuild-on-stale) is a separate mechanism documented in SSG → Build-time ISR. The two can coexist: a mode: 'isr' app with per-route revalidate exports gets BOTH runtime caching AND deploy-time ISR config.
404 handling
A src/routes/_404.tsx (or _not-found.tsx) is auto-registered as the not-found component. SSR returns HTTP 404 for unmatched URLs and renders the component inside the parent layout's chrome (header, sidebar, navigation, <PyreonUI> provider) — same router-driven render path as regular pages.
import { Meta } from '@pyreon/zero'
export default function NotFound() {
return (
<>
<Meta title="Page not found" />
<main>
<h1>404 — Page not found</h1>
<p>The page you requested doesn't exist.</p>
</main>
</>
)
}Per-route renderMode override
Any route file can override the global mode independently:
// This page is statically prerendered even though the app is mode: 'ssr'
export const renderMode = 'ssg'
export default function BlogIndex() { ... }Available values: 'ssr' | 'isr' | 'ssg' | 'spa'. Common patterns:
mode: 'ssr'globally,renderMode: 'ssg'on marketing pages → fast static landing pagesmode: 'ssr'globally,renderMode: 'spa'on a client-heavy editor route → ship the shell, render client-side
The direction matters: a server-mode app (mode: 'ssr' / 'isr') can opt routes DOWN to 'ssg'/'spa', but a static app cannot opt routes UP — under mode: 'ssg' or 'spa' a route declaring renderMode: 'ssr' or 'isr' is a hard build error (assertModesSupported throws: a static deploy has no server to render it). Only 'spa' (resp. mode-compatible) overrides are valid there. Fix: set the global mode to 'ssr'/'isr' so a server bundle is emitted — per-route 'ssg'/'spa' declarations keep those routes static.
Streaming
Streaming is the default for mode: 'ssr' — chunked transfer encoding sends the HTML shell + <head> immediately, then streams Suspense boundaries as their data resolves. Pass ssr: { mode: 'string' } to opt back into buffered rendering:
defineConfig({
mode: 'ssr', // streams by default
ssr: { mode: 'string' }, // opt back into buffered rendering
})ISR stays buffered regardless — the SWR cache stores complete response bodies, so caching a stream would either drain it (defeating streaming) or store nothing (defeating caching). This includes per-route renderMode: 'isr' routes inside an otherwise-streaming app.
import { Suspense } from '@pyreon/core'
export default function ProductPage() {
return (
<Layout>
{/* Above-the-fold renders in the initial chunk */}
<Hero />
<Suspense fallback={<RelatedSkeleton />}>
{/* Streams when the loader resolves */}
<RelatedProducts />
</Suspense>
</Layout>
)
}Streaming improves Time-To-First-Byte (TTFB) for pages with slow data. Suspense boundaries are independent — a slow one doesn't block earlier chunks. The 30-second timeout applies per-boundary; an unresolved boundary keeps its fallback.
Adapter dispatch
The configured adapter's build({ kind: 'ssr', serverEntry, clientOutDir, outDir, config }) is invoked after the SSR bundle lands. Each adapter wraps the bundle for its target platform:
| Adapter | Output | Read at deploy time |
|---|---|---|
node | dist/server/entry-server.js (default — no wrap) | node dist/server/entry-server.js |
bun | Same — Bun runs ESM natively | bun run dist/server/entry-server.js |
vercel | .vercel/output/ v3 config + serverless function | vercel deploy |
cloudflare | _worker.js + _routes.json for Pages Functions | wrangler pages publish dist |
netlify | netlify/functions/server.mjs + netlify.toml | netlify deploy |
static | Errors if mode is ssr or isr | Use ssg mode for static deploys |
Per-platform deployment
Node / Bun (self-host)
$ vite build
$ node dist/server/entry-server.js
# Server listens on PORT env var (default 3000)Vercel
adapter: 'vercel' produces .vercel/output/config.json (v3) + the serverless function. Deploy with vercel deploy or push to a connected Git repo.
import { vercelAdapter } from '@pyreon/zero/server'
defineConfig({
mode: 'ssr',
adapter: vercelAdapter(),
})Cloudflare Pages
adapter: 'cloudflare' produces _worker.js + _routes.json. Deploy with wrangler pages publish dist or via the Pages git integration.
import { cloudflareAdapter } from '@pyreon/zero/server'
defineConfig({
mode: 'ssr',
adapter: cloudflareAdapter(),
})Cloudflare runs in workerd (not Node), so two requirements apply:
nodejs_compatis required. The SSR bundle imports Node builtins (node:async_hooksfor per-request context isolation — instantiated at module load, so without the flag the worker fails to start — andnode:fs). The create-zero cloudflare scaffold sets it for you:# wrangler.toml compatibility_date = "2026-01-01" compatibility_flags = ["nodejs_compat"] pages_build_output_dir = "dist"A hand-rolled deploy must set it in the Pages dashboard (Settings → Functions → Compatibility flags) or
wrangler.toml, or pass--compatibility-flags nodejs_compattowrangler pages dev.The SSR template is inlined automatically. workerd has no filesystem, so the adapter inlines the built
index.html(with its hashed client-entry<script>) into_worker.jsat build time and reads it from a global at runtime — there's nothing to configure. Without this, SSR pages would render but never hydrate (they'd reference the dev client entry). This is transparent; it's documented here only so the larger_worker.jsis expected.
Netlify
adapter: 'netlify' produces netlify/functions/server.mjs + a netlify.toml redirect for /* → /.netlify/functions/server. Deploy with netlify deploy.
import { netlifyAdapter } from '@pyreon/zero/server'
defineConfig({
mode: 'ssr',
adapter: netlifyAdapter(),
})Migration paths
From SSG → SSR
When your content starts depending on per-request data (auth, personalization, fresh API state):
Change
mode: 'ssg'→mode: 'ssr'(or'isr'for SSR + caching)Move
getStaticPaths→loader(params come fromparamsinLoaderContextinstead of being enumerated at build time)Drop
dist/<path>/index.htmldeploy step; deploydist/server/entry-server.jsinstead via your chosen adapterKeep
_404.tsxand_layout.tsx— same shapes work across all modes
Per-route opt-in is the gentler path: keep mode: 'ssg' and add export const renderMode = 'ssr' to the one route that needs it.
From SSR → ISR
When you outgrow the per-request render cost but the content can tolerate brief staleness:
Change
mode: 'ssr'→mode: 'isr'Add
isr: { revalidate: 60 }(or whatever staleness window your content tolerates)If your loaders read cookies / Authorization headers, supply
cacheKey: (req) => `${pathname}::${session}`— the auto-warning at handler init names this trade-offIf your URLs have high-cardinality query params (
utm_*,fbclid), supplycacheKey: (req) => new URL(req.url).pathnameto strip themConsider a shared
store(Redis / KV) for multi-instance deploys
From Next.js / Remix
| Next.js / Remix concept | Pyreon equivalent |
|---|---|
getServerSideProps | export const loader |
getStaticProps | export const loader (results cached via SSG mode) |
getStaticPaths | Same name, same shape — see SSG → Dynamic routes |
revalidate: N in getStaticProps | export const revalidate = N per route |
app/api/* (Next.js App Router) | src/routes/api/*.ts — see Zero → API Routes |
next/link <Link> | <Link> from @pyreon/zero |
next/image <Image> | <Image> from @pyreon/zero/image |
unstable_cache | ISR runtime caching (mode: 'isr') or external cache layer |
redirect() from next/navigation | redirect(url, status?) from LoaderContext |
notFound() from next/navigation | notFound() from LoaderContext |