SSR, SSG & ISR
@pyreon/zero renders your app four ways, app-wide or per route:
SSG (
'ssg') — prerender to static HTML at build time. Fastest, cheapest, CDN-friendly.SSR (
'ssr') — render on each request. For per-request/auth-gated/personalized pages.ISR (
'isr') — render on first request, cache with TTL, revalidate in the background.SPA (
'spa') — ship a client shell, render entirely in the browser.
Choosing a mode
Content that's the same for everyone and changes at build time → SSG.
Content that depends on the request (cookies, auth, query) → SSR.
Mostly-static content that updates periodically → ISR.
A dashboard behind a login with no SEO needs → SPA.
App-wide mode
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero/server'
export default {
plugins: [pyreon(), zero({ mode: 'ssg' })],
}zero is the default export of @pyreon/zero/server — not a named export, and not importable from the client-safe main @pyreon/zero entry (which has no node:fs access and can't build routes). mode defaults to 'ssr' when omitted.
Per-route / hybrid rendering
Export renderMode from any route or layout to override the app default (it cascades to descendants that don't declare their own):
// src/routes/dashboard.tsx — server-render just this route inside an SSG site
export const renderMode = 'ssr'One resolver (resolveRenderModeForPath/collectRouteModes) drives both the SSG build filter and the runtime dispatch, so they never disagree — leaf wins over layout, and a route-file export always wins over the two mechanisms below. Inside an SSG app, an 'ssr'/'isr' route declaration is a build error (a static deploy has no server) — the error names the route and the fix.
routeRules — central overrides without touching route files
zero({ routeRules }) applies a render mode to every route matching a glob that has no renderMode export of its own — handy for retrofitting a policy across many routes at once:
zero({
mode: 'ssg',
routeRules: {
'/blog/**': { renderMode: 'isr' }, // every depth under /blog
'/admin/*': { renderMode: 'spa' }, // exactly one segment under /admin
},
})* matches one path segment, ** matches any depth (including zero). Matching is most-specific-first (exact path > more segments > * over **). Precedence: route-file export const renderMode > routeRules > app mode. An impossible combo (e.g. a rule declaring 'ssr' inside a mode: 'ssg' app) fails the build exactly like a route-file declaration would, naming the offending rule.
mode: 'auto' (EXPERIMENTAL) — infer per route from exports
Pass mode: 'auto' to zero() and every route's mode is inferred from what it exports, instead of defaulting to the app mode:
export const revalidate→'isr'export const getStaticPaths→'ssg'(an enumerator is a static-intent signal, even alongside aloader— SSG runs loaders at build)loader/serverLoader/guard/middleware→'ssr'otherwise →
'ssg'
An explicit renderMode export or a routeRules match still wins. mode: 'auto' widens only zero()'s own parameter — ZeroConfig.mode (what resolveConfig/defineConfig accept) never carries 'auto'; it's resolved to a concrete app-level mode ('ssr' if any page needs server rendering, else 'ssg') once, up front, at plugin-factory time. The build's per-route mode table shows 'auto' as the app mode plus each route's inferred value, so the inference is always visible, never silent.
SSG: static paths
Dynamic routes need to enumerate their concrete URLs at build time:
// src/routes/posts/[id].tsx
export const getStaticPaths = () => [
{ params: { id: 'hello' } },
{ params: { id: 'world' } },
]Catch-all routes use { params: { slug: 'a/b' } } → /blog/a/b. A _404.tsx co-located with _layout.tsx is emitted as dist/404.html with layout chrome.
ISR caching
There are two separate ISR mechanisms, both named revalidate — different enforcement points, deliberately the same word ("how stale may this page get"):
Build-time, per-route
export const revalidate— a route file export. It feeds a build-timedist/_pyreon-revalidate.jsonmanifest that a platform adapter (Vercel/Netlify/Cloudflare) reads for its own rebuild-on-stale primitive. Must be a pure literal — inlining a variable or expression is silently dropped (see pitfalls below).// src/routes/posts/[id].tsx export const revalidate = 3600 // secondsRuntime
mode: 'isr'+zero({ isr })— an in-memory (or pluggable-store) stale-while-revalidate cache the request goes through on every hit:zero({ mode: 'isr', isr: { revalidate: 60, maxEntries: 1000 }, })ISR keys the cache by
pathname + searchby default. Cookies/Authorization are not included by default — a request that arrives WITH credentials is fail-safe by default (not cached, re-rendered every time) unless the response marks itselfCache-Control: public. For a cacheable auth-gated page, supplycacheKeyso one user's HTML isn't served to another:zero({ mode: 'isr', isr: { revalidate: 60, cacheKey: (req) => `${new URL(req.url).pathname}::${sessionOf(req)}`, }, })The same
ISRConfigshape drivescreateISRHandlerdirectly (@pyreon/zero/server) if you're wiring the cache around your own handler outsidemode: 'isr':import { createISRHandler } from '@pyreon/zero/server' const handler = createISRHandler( async (req) => new Response(await renderPage(req)), { revalidate: 60, cacheKey: (req) => `${new URL(req.url).pathname}::${sessionOf(req)}` }, )
Common pitfalls
mode: 'ssr'/'isr'route in an SSG app. Build error by design — a static deploy has no server.Dynamic route without
getStaticPathsunder SSG. No page is emitted for it — the build prints a[Pyreon] SSG: dynamic route "…" has no \getStaticPaths`warning naming the fix (enumerate it, hand-list it inssg.paths, or declarerenderMode = 'spa'if the client shell is intended). Also caught earlier, at edit time, bypyreon doctor --check-ssgand thepyreon/missing-get-static-paths` lint rule.revalidateas a non-literal.export const revalidate = TTLis dropped from the build manifest silently (only a pure numeric/falseliteral is captured) — inline the number:export const revalidate = 3600. Thepyreon/revalidate-not-pure-literallint rule catches this at edit time.Confusing the two
revalidateknobs. A route file'sexport const revalidateis build-time-only (the platform-manifest signal) and does NOT set the runtime ISR TTL —zero({ isr: { revalidate } })is the separate runtime knob.ISR on an auth-reading page without a custom
cacheKeyFUNCTION. No longer leaks by default (the fail-safe default refuses to cache a credentialed request unless the response isCache-Control: public) — but it silently degrades to uncached-per-request SSR, and the build warns when it detects a cookie/Authorization read on an ISR route with no customcacheKey. Supply acacheKeythat varies on the session identifier to actually cache it.