@pyreon/zero is a batteries-included meta-framework for Pyreon, similar to Next.js for React or Nuxt for Vue. It provides file-system routing, SSR/SSG/ISR/SPA rendering modes, optimized components, a theme system, SEO utilities, font/image optimization, and production deployment adapters — everything you need to ship a full-stack Pyreon application.
Installation
npm install @pyreon/zerobun add @pyreon/zeropnpm add @pyreon/zeroyarn add @pyreon/zeroQuick Start
Scaffold a new project with the create command:
bun create @pyreon/zero my-app
cd my-app
bun install
bun run devThis gives you a working application with file-system routing, SSR, and hot module replacement out of the box. The dev server defaults to port 3000; override with --port or zero({ port }).
Client-Safe vs Server-Only Entry Points
@pyreon/zero is split into a small client-safe main entry and a larger server-only entry. This is the single most important thing to get right when importing from Zero.
| Entry | Contains |
|---|---|
@pyreon/zero | Browser-safe only: Image, Link, Script, Icon, Meta, theme system, i18n hooks, plus types |
@pyreon/zero/server | Everything that touches node:fs/node:path: createServer, createApp, defineConfig, resolveConfig, adapters, seoPlugin, aiPlugin, i18nRouting, fs-router helpers, vercelRevalidateHandler |
The server APIs (createServer, defineConfig, seoPlugin, faviconPlugin, validateEnv, ogImagePlugin, aiPlugin) are not exported from @pyreon/zero at all — import each from its subpath (@pyreon/zero/server, /config, /seo, /favicon, /env, /og-image, /ai). Importing one from the main entry is a structural compile error (TS2305: '@pyreon/zero' has no exported member 'createServer'), and no server-only code reaches the client bundle:
// ✅ client-safe
import { Image, Link, Icon, theme } from '@pyreon/zero'
// ✅ server-only — from the subpath
import { createServer, defineConfig } from '@pyreon/zero/server'
// ❌ compile error: TS2305 — '@pyreon/zero' has no exported member 'createServer'
import { createServer } from '@pyreon/zero'A handful of features live on their own focused subpaths (so a client bundle that imports corsMiddleware doesn't pull in unrelated server code) — see Subpath Exports.
Single-instance contract
Pyreon's @pyreon/* packages MUST resolve to exactly one copy per heap. Multiple instances of the same package (Vite resolver divergence, sub-dep version mismatch, workspace+npm mix) break the framework's contracts silently — runWithHooks sets _current on instance A, onMount reads _current from instance B, warning storms ensue. Pyreon ships two layers to enforce this:
Bundler prevention (default-on).
@pyreon/vite-plugininjectsresolve.dedupe: <all @pyreon/* + transitive>automatically — walksnode_modules/@pyreonfor the full set (direct AND transitive deps a direct dep pulled in). Zero config; works for every Vite-driven app. Override withPYREON_DISABLE_DEDUPE=1only if you have a specific need to allow duplicate resolution (rare).Runtime detection (default-on). Every
@pyreon/*package with module-level state callsregisterSingleton(...)at module load. If two distinct module instances of the same package register, the second registration throws an actionableErrornaming both file paths + three concrete fixes. Demote to a warning withPYREON_SINGLE_INSTANCE=warn; disable entirely withPYREON_SINGLE_INSTANCE=silent(browser extensions, micro-frontends, nested SSR test harnesses).
For non-Vite bundlers, you need the equivalent configuration manually:
Webpack / Next.js:
resolve.aliasevery@pyreon/<name>to a single absolute path, OR usemodule.rules.resolve.symlinks: falseif your bundler resolves through a unified workspace.Rollup:
@rollup/plugin-node-resolve+dedupe: ['@pyreon/<name>', ...].esbuild: no native dedupe; use a plugin or symlinks.
pyreon doctor --check-dedup audits any consumer's lockfile (bun.lock / package-lock.json / pnpm-lock.yaml) for @pyreon/* packages with more than one resolved version — surfaces duplicates BEFORE runtime detection fires.
Configuration
Zero is configured as a set of Vite plugins in your vite.config.ts. The default export of @pyreon/zero/server is the Zero Vite plugin:
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero/server'
import { fontPlugin } from '@pyreon/zero/font'
import { seoPlugin } from '@pyreon/zero/seo'
export default {
plugins: [
pyreon(),
zero({ mode: 'ssr' }),
fontPlugin({ google: ['Inter:wght@400;500;700'] }),
seoPlugin({ sitemap: { origin: 'https://example.com' } }),
],
}zeroPlugin() returns Plugin[] — the main plugin plus mode companions (the SSG plugin for ssg and hybrid server modes, the SSR plugin for ssr/isr) and the build-summary plugin. Vite's plugins array natively accepts nested arrays, so plugins: [pyreon(), zero()] works unchanged in all modes.
For type-safe config, use defineConfig from @pyreon/zero/server (or @pyreon/zero/config):
import { defineConfig } from '@pyreon/zero/server'
export default defineConfig({
mode: 'ssr',
adapter: 'node',
port: 3000,
})ZeroConfig Options
| Option | Type | Default | Description |
|---|---|---|---|
mode | "ssr" | "ssg" | "spa" | "isr" | "ssr" | Global rendering mode |
vite | Record<string, unknown> | {} | Vite config overrides |
ssr.mode | "stream" | "string" | "stream" when mode: "ssr", "string" otherwise | SSR output mode |
ssg | { paths?, emit404?, emitRedirects?, redirectsAsHtml?, onPathError?, errorArtifact?, concurrency?, onProgress?, splitChunks?, speculationRules?, viewTransitions?, cssMode?, earlyHints?, modulePreload? } | {} | SSG options — see SSG |
isr | ISRConfig ({ revalidate, maxEntries?, cacheKey?, store?, tagsForRequest? }) | — | Runtime ISR config (only used when mode: "isr") |
adapter | "node" | "bun" | "static" | "vercel" | "cloudflare" | "netlify" | Adapter | auto | Deployment adapter (name or constructed instance). When unset, the build platform is auto-detected from its env (VERCEL / NETLIFY / CF_PAGES) and that adapter is used — local builds default to "node" |
base | string | "/" | Base URL path — single source of truth (see Base Path) |
i18n | I18nRoutingConfig | — | Build-time locale-prefixed route duplication — see SSG → i18n |
middleware | Middleware[] | [] | Global server middleware |
port | number | 3000 | Dev/preview server port |
typedRoutes | boolean | false | Generate src/pyreon-routes.d.ts so <Link href> autocompletes your routes — see Typed routes |
seo | SeoPluginConfig | — | Auto-wires seoPlugin (sitemap.xml, robots.txt, RSS) — same shape as the plugin config, no manual import. See SEO |
favicon | FaviconPluginConfig | false | auto | Explicit config wires faviconPlugin; omitted → file-convention auto-detect (src/favicon.svg → full set, zero config); false disables — see Favicons |
theme | boolean | false | true auto-injects the pre-paint themeScript into every page <head> (no manual script tag) — see Theme System |
og | OgImagePluginConfig | — | Auto-wires ogImagePlugin (templates + text layers → per-locale social-share images) |
ai | AiPluginConfig | — | Auto-wires aiPlugin (llms.txt, llms-full.txt, /.well-known/ai-plugin.json, OpenAPI spec) |
resolveConfig(userConfig?) merges user config with the defaults above (mode: 'ssr', base: '/', port: 3000, adapter: 'node'). When no ssr.mode is set, the server picks the effective default at runtime: 'stream' for mode: 'ssr', 'string' for every other mode — pass ssr: { mode: 'string' } to opt an SSR app back into buffered rendering.
Build Output
Every production build ends with a branded summary of what it produced — after the SSG prerender, SSR bundle, and deploy-adapter staging have all finished:
▲ pyreon zero production build
Client assets
entry assets/index-CfGHbruy.js 92.0 kB │ gzip 31.3 kB
entry assets/index-Gt2BXkg9.css 3.9 kB │ gzip 1.2 kB
js assets/blog-K42cx7wd.js 852 B │ gzip 466 B
Σ 4 js 94.5 kB (gzip 32.8 kB) · 1 css 3.9 kB (gzip 1.2 kB) · 13 other 262.2 kB
Server bundle
file server/entry-server.js 301.2 kB
○ 9 prerendered pages (158.8 kB html)
✓ Build complete in 463 msentry marks the scripts/styles the built
index.htmlloads directly — the initial payload; lazy route chunks list below it, sorted by gzip cost, with long tails collapsed to+ N more.The per-route mode table (
○ ssg · λ ssr · ⟳ isr · ⚡ spa) prints above the summary, colorized in the same palette.Colors use the Pyreon ember ramp and degrade automatically: truecolor → 16-color ANSI → plain (
NO_COLOR, non-TTY, andTERM=dumbare respected;FORCE_COLORopts back in for CI log viewers).The summary is informational only — it can never fail a build — and prints exactly once per top-level build (zero's recursive inner sub-builds stay silent).
Opt out for minimal logs (log scrapers, size-diff tooling):
zero({ buildSummary: false })File-System Routing
Routes live in src/routes/. The file path maps directly to the URL:
| File | URL |
|---|---|
src/routes/index.tsx | / |
src/routes/about.tsx | /about |
src/routes/users/[id].tsx | /users/:id |
src/routes/blog/[...slug].tsx | /blog/* |
Special Files
| File | Purpose |
|---|---|
_layout.tsx | Wraps all routes in the same directory and subdirectories |
_error.tsx | Error boundary for the route segment |
_loading.tsx | Loading/suspense fallback for the route segment |
_404.tsx | Not-found page (also _not-found.tsx) — drives runtime + SSG 404s |
Route Groups
Wrap a directory name in parentheses to group routes without affecting the URL:
src/routes/
(auth)/
login.tsx → /login
register.tsx → /register
(marketing)/
pricing.tsx → /pricingGroups are URL-invisible but they are real route-tree boundaries — a group can carry its own _layout.tsx / _error.tsx / _loading.tsx that scope to exactly the group's routes, nested under the root layout:
src/routes/
_layout.tsx → wraps everything
index.tsx → / (root layout only)
(app)/
_layout.tsx → wraps only the group's routes
dashboard.tsx → /dashboard (root layout → app layout → page)
(marketing)/
pricing.tsx → /pricing (root layout only — no group layout here)This is the canonical "different chrome per section, same URL space" shape: an authenticated (app) shell and a public (marketing) shell without /app/ or/marketing/ URL prefixes. Sibling groups are fully isolated — each group's specials apply only to its own subtree.
Virtual Modules
The plugin generates three virtual modules you import in your entry files:
import { routes } from 'virtual:zero/routes'
import { routeMiddleware } from 'virtual:zero/route-middleware'
import { apiRoutes } from 'virtual:zero/api-routes'Add their types to env.d.ts:
declare module 'virtual:zero/route-middleware' {
import type { RouteMiddlewareEntry } from '@pyreon/zero'
export const routeMiddleware: RouteMiddlewareEntry[]
}Typed routes
Turn typedRoutes on and <Link href> (and any RouteHref-typed API) autocompletes your real route paths and rejects typos at compile time — the TanStack-Router / SvelteKit / Next-typed-routes bar, built on the standard module-augmentation pattern.
import { zero } from '@pyreon/zero/config'
export default {
plugins: [pyreon(), zero({ typedRoutes: true })],
}The plugin scans your page routes (layouts, _error, _loading, _404 are skipped — they have no navigable path) and writes src/pyreon-routes.d.ts, regenerated on route add/remove. Your tsconfig include: ["src"] picks it up automatically:
// src/pyreon-routes.d.ts — auto-generated, do not edit
declare module '@pyreon/zero' {
interface RegisteredRoutes {
'/': Record<string, never>
'/about': Record<string, never>
'/posts/:id': { id: string }
}
}
export {}<Link href="/posts/42" /> // ✓ concrete path matches the `/posts/:id` pattern
<Link href="/postz/42" /> // ✗ type error — unknown route (did you mean /posts/:id?)
<Link href={dynamicPath} /> // ✓ dynamic string, no cast
<Link href="https://elsewhere.com" />{/* ✓ external URL — auto target="_blank" rel="noopener noreferrer" */}href is validated by CheckHref (a strict superset of string): registered routes (including concrete paths matching a :param pattern via InterpolateRoute) and external URLs are accepted, a mistyped internal path is a compile error, and any runtime stringstill flows through with no cast. External links are also auto-handled at runtime — see<Link> external links.
Add the generated file to .gitignore — it's build output. RouteParams<'/posts/:id'> is typed as { id: string }. Off by default (no surprise file writes); until codegen runs,href stays string, so existing code never breaks.
src/pyreon-routes.d.tsRoute Module Exports
Each route file can export any combination of:
// src/routes/users/[id].tsx
// Required: the page component
export default function UserPage({ params, data }) {
return (
<div>
User {params.id}: {data.name}
</div>
)
}
// Optional: server-side data loading
export async function loader({ params, query, request, signal }) {
const user = await db.users.findById(params.id)
return { name: user.name }
}
// Optional: per-route middleware (@pyreon/server signature)
export const middleware = (ctx) => {
if (!ctx.req.headers.get('authorization')) return new Response('Unauthorized', { status: 401 })
}
// Optional: navigation guard
export function guard({ params }) {
return params.id ? true : '/404'
}
// Optional: head/meta tags
export const meta = { title: 'User Profile', description: 'View user profile details' }
// Optional: per-route rendering mode override
export const renderMode = 'ssr' // "ssr" | "ssg" | "spa" | "isr"
// SSG-only: enumerate concrete params for a dynamic route
export const getStaticPaths = () => [{ params: { id: 'a' } }, { params: { id: 'b' } }]
// SSG/ISR: build-time revalidate interval (seconds, or `false` for never)
export const revalidate = 60
// Loader cache controls
export const loaderKey = ({ params }) => `user-${params.id}`
export const gcTime = 0 // disable loader caching for auth-gated routesgetStaticPaths, revalidate, loaderKey, and gcTime are documented in depth in the SSG reference (the first two) and router docs (the last two).
Loader Context
The loader receives a LoaderContext (re-exported from @pyreon/router):
| Property | Type | Description |
|---|---|---|
params | Record<string, string> | Dynamic route parameters |
query | Record<string, string> | URL search parameters |
request | Request | undefined | The incoming HTTP request — populated only during SSR/SSG preload |
signal | AbortSignal | Abort signal for cancellation |
Throw redirect(url, status?) from @pyreon/router inside a loader to redirect before the layout renders — handled at SSR, CSR, and SSG (see SSG → Loader redirects).
Rendering Modes
SSR (Server-Side Rendering)
The default mode. Pages are rendered on the server for every request and hydrated on the client. SSR auto-bundles the server handler to dist/server/entry-server.js at build time and dispatches to platform adapters (vercel / cloudflare / netlify / node / bun). See the dedicated SSR & ISR reference for the build pipeline, loaders, streaming, runtime caching, the synthetic-vs-user-entry contract, and per-platform deployment.
defineConfig({
mode: 'ssr',
ssr: { mode: 'string' }, // streaming is the default for mode: 'ssr'; 'string' opts back to buffered
})SSG (Static Site Generation)
Pages are prerendered to static HTML at build time. Ideal for content that rarely changes. SSG has a substantial feature surface — see the dedicated SSG reference.
defineConfig({
mode: 'ssg',
ssg: { paths: ['/', '/about', '/blog/hello-world'] },
})SPA (Single-Page Application)
Client-only rendering. The server sends a minimal HTML shell and all rendering happens in the browser.
defineConfig({ mode: 'spa' })ISR (Incremental Static Regeneration — runtime)
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` — query strings vary the cache.
// Cookies are NOT included by default (auth-gated content requires explicit cacheKey).
},
})Build-time ISR (per-route export const revalidate + adapter-driven rebuild-on-stale) is a separate mechanism documented in SSG → Build-time ISR.
Per-route render modes (hybrid rendering)
mode is the default for routes that don't declare their own. Any route file (or layout — declarations cascade to descendants) can override it:
// src/routes/about.tsx — prerendered at build inside an SSR app
export const renderMode = 'ssg'
// src/routes/dashboard.tsx — opt OUT of SSR: the server responds with the
// CSR shell, the client mounts fresh and runs loaders on the cold-start path
export const renderMode = 'spa'
// src/routes/pricing.tsx — per-route stale-while-revalidate caching
export const renderMode = 'isr'
// src/routes/blog/_layout.tsx — cascades 'ssg' to every /blog/** route
// that doesn't declare its own renderMode
export const renderMode = 'ssg'Resolution is leaf-first along the matched chain: a page's own declaration beats its layout's, and a layout's beats the app default. One implementation (resolveRenderModeForPath) drives both the build and the runtime, so they can never disagree.
What each declaration does inside a server app (mode: 'ssr' | 'isr'):
| renderMode | Build | Runtime |
|---|---|---|
'ssg' | Prerendered to dist/<path>/index.html (loaders run at build; getStaticPaths expands dynamic routes) + listed in _pyreon-ssg-paths.json | Served static-first by the emitted node/bun servers and excluded from the Cloudflare worker via _routes.json; a missing file falls back to SSR — never a 404 |
'spa' | Nothing emitted | The server responds with the CSR shell (built template, placeholders blanked, GET-only); the client mounts + runs loaders |
'isr' | — | Routed through the shared SWR cache handler (config.isr, default revalidate: 60) |
'ssr' | — | Under an 'isr' app mode this is the per-route cache bypass |
Inside a static app (mode: 'ssg'): 'spa' routes emit the CSR shell instead of prerendered HTML; declaring 'ssr' or 'isr' is a build error (a static deploy has no server — the error names each offending route and the fix). Vercel/Netlify serve prerendered files static-first by platform convention; for subpath (base) deploys on Cloudflare the _routes.json excludes are root-relative — verify them when combining base with hybrid.
When no route declares a divergent mode, the pipeline is byte-identical to the app-level mode — existing apps are unaffected.
mode: 'auto' (EXPERIMENTAL) — let zero decide. Inference follows the conservative "static unless the code says otherwise" model, per route: a revalidate export → isr; a getStaticPaths export → ssg (an enumerator is a static-intent signal, even alongside a loader — SSG runs loaders at build); a loader / .server.ts sibling / guard / middleware → ssr (request-coupled work inference can't prove build-safe); otherwise → ssg. Explicit renderMode exports and routeRules always win — inference only fills the undeclared gaps. The app-level pipeline is derived from the result (any server-needing route → server build; else pure static), announced at startup, and the build mode table shows every inferred decision — inference is never invisible. Escape hatch per route: declare export const renderMode = 'ssg' on a loader route you know is build-safe.
Central overrides — routeRules. When you'd rather declare a mode policy in ONE place than touch route files (retrofits, monorepo conventions), zero({ routeRules }) maps path globs to modes — * matches one segment, ** any depth, most-specific key wins:
zero({
mode: 'ssr',
routeRules: {
'/blog/**': { renderMode: 'isr' },
'/admin/**': { renderMode: 'spa' },
},
})Precedence is always: route-file export const renderMode (closest to the code) > routeRules > app mode. Rule-sourced modes appear in the build mode table and count as declarations for the impossible-combo build errors (the error names the offending rule).
You can always SEE the resolved modes. Every production build prints a per-route mode table — ○ ssg, λ ssr, ⟳ isr, ⚡ spa, with (declared) marking per-route overrides — and zero dev's banner shows the app mode plus any hybrid overrides (Mode ssr (hybrid: 2 ssg, 1 isr)); zero dev --routes lists each route with its true resolved mode. Apps above 40 routes collapse the build table to the counts line.
No silent missing pages. Under SSG, a dynamic route ([id].tsx) with no getStaticPaths cannot be enumerated — the build now warns loudly, naming the file and the three fixes (add getStaticPaths, hand-list ssg.paths, or declare renderMode = 'spa' if a client-rendered shell is intended). Routes that declare a non-static mode and API routes are exempt.
Streaming by default (mode: 'ssr')
mode: 'ssr' streams by default: the shell flushes immediately and Suspense boundaries resolve out-of-order (styles flush inline per boundary, so streamed content arrives styled). Opt back into buffered rendering with ssr: { mode: 'string' }. ISR apps stay buffered — the SWR cache stores complete bodies; a per-route renderMode = 'isr' declaration inside a streaming app automatically uses a buffered render for the cached routes.
Server Islands
The inverse of client islands: a cacheable page with per-request server-rendered holes. island() defers client hydration of an interactive component; serverIsland() defers SERVER rendering of a fragment whose content depends on the request (session, cookies, A/B bucket, live data) — so the page around it can be prerendered, ISR-cached, or CDN-cached while the hole stays personalized.
import { serverIsland } from '@pyreon/zero'
const CartBadge = serverIsland(() => import('../islands/CartBadge'), {
name: 'CartBadge',
fallback: <span class="badge">Cart</span>, // shown until the fragment arrives (and for no-JS)
})
// In any route/layout — the page emits ONLY a marker here:
<CartBadge label="Cart" />How it works: every render emits only a <pyreon-server-island> marker (name + codec-encoded props — the same lossless codec client islands use). On the client the marker self-activates on mount and fetches GET /_pyreon/fragment/<name>?props=… — an endpoint createServer auto-mounts — then swaps the returned HTML in. The fragment renders per request with full request context (useRequestLocals() works inside it, exactly like a page).
Contracts worth knowing:
Always deferred — even on a fully-SSR page the island arrives via the fragment fetch. That's what makes the surrounding page's cacheability unconditional.
Allowlisted endpoint — only registered island names render; unknown names 404. Hostile
propsreturn 400, never a 500.Fragments default to
Cache-Control: no-store. Thecacheoption sets a custom value for deferred-but-public fragments — never cache a fragment that varies on cookies (the same auth caveat as ISR'scacheKey).Failure degrades to the fallback — a failed fragment fetch leaves the fallback content in place and flags the marker (
data-island-error="fragment-failed"); the page never breaks.Cold starts work: the endpoint warms lazy route modules once on a registry miss, so a fragment request hitting a freshly-restarted server (pages served from CDN cache) still resolves.
Pages without a full Pyreon mount (static-islands apps) call
activateServerIslands()from@pyreon/server/clientonce after load.Composing a client
island()INSIDE a server island is not supported in v1.
Delivery polish (SSG toggles)
Four opt-in ssg options, each verified on real builds:
zero({
mode: 'ssg',
ssg: {
speculationRules: 'prefetch', // or 'prerender' — Chrome Speculation Rules; near-instant MPA navs
viewTransitions: true, // cross-document View Transitions (@view-transition CSS, zero JS)
cssMode: 'asset', // styler CSS as ONE hashed shared file instead of inlined per page
earlyHints: true, // per-path Link: modulepreload entries in _headers → HTTP 103 on CF/Netlify
},
})speculationRulesinjects a document-rules block (href_matches: "/*", moderate eagerness) into every prerendered page; unsupported browsers ignore it.viewTransitionsopts prerendered pages into cross-document View Transitions — MPA navigations animate with zero JS in supporting browsers.cssMode: 'asset'extracts the styler's per-page inline<style>(identical across pages by construction) into one content-hashedassets/pyreon-ssg.<hash>.cssthat every page links — pages share the browser-cached file instead of re-downloading the full sheet inside each HTML. No-op for projects without@pyreon/styler.earlyHintsappends per-pathLink: <chunk>; rel=modulepreloadentries to_headers(existing user_headerscontent is preserved); Cloudflare Pages and Netlify turn those into HTTP 103 Early Hints.
ISR: tag-based invalidation + filesystem store
import { createFsStore } from '@pyreon/zero/server'
zero({
mode: 'isr',
isr: {
revalidate: 60,
store: createFsStore('./.isr-cache'), // survives restarts (single-box node/bun)
tagsForRequest: (req) => {
const p = new URL(req.url).pathname
return p.startsWith('/posts/') ? ['posts', `post:${p.split('/')[2]}`] : []
},
},
})
// webhook handler:
await isrHandler.revalidateTag('posts') // drops every page that rendered poststagsForRequest records tags at cache-set time; revalidateTag(tag) drops every entry carrying the tag — the webhook-ergonomic unit (no path enumeration). Both shipped stores (createMemoryStore, createFsStore) implement the tag index; custom stores add setTags/keysByTag. createFsStore(dir) persists entries (and the tag index) as JSON files so a restart doesn't cold-start the cache — multi-instance deploys still want a shared external store (Redis/KV).
Server Loaders
Route loaders are isomorphic by default — they run on the server during SSR and in the browser on client-side navigations, so they can't touch a database, a secret, or a server-only SDK. A server loader can: put it in a .server.ts sibling next to the route file.
// src/routes/dashboard.server.ts — NEVER ships to the client
import { db } from '../lib/db'
export async function serverLoader(ctx: { request?: Request }) {
const session = ctx.request?.headers.get('cookie') // cookies flow on SSR AND navigations
return db.dashboardFor(session)
}// src/routes/dashboard.tsx — reads it like any loader
const data = useLoaderData<Dashboard>()How it works:
SSR / SSG / fragments: the serverLoader runs in-process, exactly like a
loader(fullLoaderContext, includingrequest).Client navigations: the router fetches
GET /_pyreon/data?path=<to>— ONE request for the whole matched chain (single-fetch), auto-mounted bycreateServer. Cookies flow (same-origin). Aredirect()thrown in a serverLoader comes back as a JSON envelope and becomes a client-side navigation.Every server-module extension is recognized:
.server.ts,.server.tsx,.server.js, and.server.jsxsiblings are all picked up as server-loader modules AND all excluded from the client bundle.Bundle exclusion is structural: the client routes module never imports
.server.*files — the server code can't leak into the client bundle by construction (gated in CI by an artifact-level sentinel scan).A route may have
loaderOR a.server.tssibling, not both — the build fails with the fix spelled out (move the public fetching into the serverLoader).Layouts can't have server loaders —
_layout.server.tssiblings are deliberately not picked up. Put per-request layout data in a page's serverLoader or in middleware locals (useRequestLocals()).staleWhileRevalidatedoes not apply to server-loader records.
Isomorphic loader stays the right tool for public-API fetching that benefits from running client-side.
Components
All four components are client-safe and follow the same three-layer extensibility pattern: a useX composable (full control), a createX HOC (wrap any component with the behavior), and a default component.
Link
Client-side navigation with prefetching and active state tracking.
import { Link, useLink, createLink, prefetchRoute } from '@pyreon/zero'
;<Link href="/about">About</Link>
<Link href="/dashboard" prefetch="hover">Dashboard</Link>
<Link href="/settings" prefetch="viewport">Settings</Link>
<Link href="/admin" prefetch="none">Admin</Link>| Prop | Type | Default | Description |
|---|---|---|---|
href | CheckHref<T> | required | Navigation target — a route, dynamic string, or external URL (typo-rejects a mistyped route once typedRoutes is on) |
prefetch | "hover" | "viewport" | "none" | "hover" | When to prefetch the route |
activeClass | string | "active" | Class when the link matches the current route |
exactActiveClass | string | "exact-active" | Class for exact route match |
external | boolean | auto | Force external (true) / internal (false), overriding auto-detection |
target | string | auto | Override <a target> (auto "_blank" for external) |
rel | string | auto | Override <a rel> (auto "noopener noreferrer" on new-tab links) |
<Link> external links
<Link> classifies href at runtime and only intercepts INTERNAL navigations. External URLs, mailto:/tel:, and #hash are left to the browser — and external links auto-get target="_blank" rel="noopener noreferrer". No manual external prop needed:
<Link href="/docs">Docs</Link> {/* client nav + prefetch */}
<Link href="https://github.com/pyreon">GitHub</Link> {/* new tab, secure rel */}
<Link href="mailto:hi@pyreon.dev">Email</Link> {/* browser mailto */}
<Link href="/report.pdf" external>PDF</Link> {/* force external for a same-origin asset */}
<Link href="https://x.com" target="_self">stay</Link> {/* override the auto target */}Same-origin absolute URLs are treated as internal by default (stripped to their path and client-navigated). Configure globally with createApp({ links: { sameOriginAbsolute, externalNewTab, externalRel } }); overrides win over config, config wins over auto-detection. Modifier / middle-clicks always fall through to the browser.
Three layers:
// 1. useLink — composable, full control
function CustomNav({ href, children }) {
const { isActive, isExactActive, navigate, prefetch } = useLink({ href })
return (
<button class={isActive() ? 'active' : ''} onClick={navigate} onMouseEnter={prefetch}>
{children}
</button>
)
}
// 2. createLink — wrap any component with link behavior
const FancyLink = createLink((props) => (
<button class={props.isActive() ? 'fancy-active' : 'fancy'} onClick={props.onClick}>
{props.children}
</button>
))
// 3. Link — the default <a>-based link (itself built via createLink)prefetchRoute('/about') imperatively prefetches a route's chunk + loader data.
Image
Optimized image with lazy loading, responsive srcset, multi-format <picture>, and blur-up placeholders.
import { Image, useImage, createImage } from '@pyreon/zero'
;<Image
src="/photos/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // skip lazy loading for above-the-fold images
placeholder="data:image/svg+xml;base64,..." // low-quality placeholder URL/data URI
sizes="(max-width: 768px) 100vw, 50vw"
/>useImage(props) returns the resolved src/srcset/sizes, the container ref, load state, and style accessors; createImage(Component) wraps any component with ImageRenderProps (pre-rendered <img> + placeholder + ref/styles). The default <Image> is built via createImage.
Build-time optimization (responsive variants, modern formats, generated placeholders) is handled by imagePlugin.
Script
Optimized third-party script loading.
import { Script, useScript, createScript } from '@pyreon/zero'
;<Script src="https://cdn.example.com/critical.js" strategy="beforeHydration" />
<Script src="https://cdn.example.com/analytics.js" strategy="afterHydration" />
<Script src="https://cdn.example.com/widget.js" strategy="onIdle" />
<Script src="https://cdn.example.com/chat.js" strategy="onInteraction" />
<Script src="https://cdn.example.com/video.js" strategy="onViewport" />| Strategy | Description |
|---|---|
beforeHydration | Loads synchronously before the app hydrates. Use sparingly. |
afterHydration | Loads asynchronously after hydration completes. |
onIdle | Loads during requestIdleCallback. Good for non-critical scripts. |
onInteraction | Loads on first user interaction (click, scroll, keydown). |
onViewport | Loads when the script's container enters the viewport via IntersectionObserver. |
Same three-layer shape: useScript(props) / createScript(Component) / Script.
Icon
A minimal inline-SVG leaf — renders an SVG you loaded, container-sizable and theme-aware (fill="currentColor" by default, so CSS color themes it). Two ways to supply the SVG:
import { Icon, createIcon, createNamedIcon } from '@pyreon/zero'
// Component form (recommended) — import the SVG as a component
import Check from './check.svg?component'
;<span style="width:2rem"><Icon as={Check} /></span>
// Raw-markup form — import the SVG as a string
import check from './check.svg?raw'
;<span style="width:2rem"><Icon svg={check} /></span>
// Factory: one reusable component per glyph
export const CheckIcon = createIcon(check) // or createIcon(Check)
;<span style="width:48px"><CheckIcon class="text-green-600" /></span>There is intentionally no useIcon — an icon has no composable behavior. For a folder of icons, iconsPlugin scans a directory and generates a strictly-typed icon set. In inline mode the generated icons.gen.tsx exports two shapes:
// PREFERRED — per-icon components. Tree-shakeable: import only what you use
// and every unused icon (plus the runtime registry) is dropped from the
// bundle by standard ESM dead-code elimination.
import { CheckCircle, ArrowLeft } from './icons.gen'
;<span style="width:2rem"><CheckCircle /></span>
// Escape hatch — <Icon name="..."> for DYNAMIC / data-driven names. A runtime
// registry[name] lookup, so it necessarily keeps the WHOLE set in the bundle.
import { Icon } from './icons.gen'
;<Icon name={iconKey()} />Use the per-icon bindings for a bounded, statically-named set (they tree-shake); reserve <Icon name={…}> for names you only know at runtime. Image-mode sets stay registry-only (createIcon renders raw ?raw markup, not an <img>).
Meta
Per-page head tags, including Open Graph + SEO.
import { Meta } from '@pyreon/zero'
;<Meta
title="My Page"
description="Page description"
ogImage={{ url: '/og.png', width: 1200, height: 630 }}
ogVideo={{ url: '/video.mp4', type: 'video/mp4' }}
ogAudio={{ url: '/audio.mp3', type: 'audio/mpeg' }}
noIndex
ogTemplate="default"
favicon="/custom-favicon.svg"
/>buildMetaTags(props) returns the tag list directly if you need to compose head output yourself.
Auto-canonical. Give <Meta> an origin and it derives the canonical URL (and therefore og:url) from the current route path — every page carries <link rel="canonical"> with zero per-route boilerplate (missing canonicals are the classic duplicate-content SEO footgun):
<Meta title="Post" origin="https://example.com" />
// on /blog/my-post → <link rel="canonical" href="https://example.com/blog/my-post">
// + <meta property="og:url" content="https://example.com/blog/my-post">An explicit canonical prop always wins; pass autoCanonical={false} to suppress derivation.
Theme System
Built-in dark/light theme with FOUC prevention. All theme APIs are client-safe.
import {
theme,
resolvedTheme,
toggleTheme,
setTheme,
initTheme,
ThemeToggle,
themeScript,
setSSRThemeDefault,
} from '@pyreon/zero'
function Header() {
return (
<header>
<p>Theme: {theme()}</p>
<p>Resolved (system-aware): {resolvedTheme()}</p>
<button onClick={toggleTheme}>Toggle</button>
<button onClick={() => setTheme('system')}>System</button>
<ThemeToggle />
</header>
)
}| Export | Type | Description |
|---|---|---|
theme | () => "light" | "dark" | "system" | Current theme preference (reactive) |
resolvedTheme | () => "light" | "dark" | Resolved theme — reactive to OS color-scheme changes |
toggleTheme | () => void | Toggle between light and dark |
setTheme | (t) => void | Set theme explicitly |
initTheme | () => void | Initialize from storage/system on startup |
ThemeToggle | Component | Pre-built toggle button |
themeScript | string | Inline <script> to apply theme before first paint |
setSSRThemeDefault | (t) => void | Set the theme used during SSR render |
Auto-injection. Set zero({ theme: true }) and the pre-paint script is injected into every page's <head> (prepended, so it runs before stylesheets paint) — no manual <script>{themeScript}</script> step. Just call initTheme() on startup (or render a <ThemeToggle>, which does it for you). The injected content is byte-identical to themeScript, so themeScriptCspHash covers it under a strict CSP unchanged.
Manual alternative: add themeScript to your HTML <head> yourself and call initTheme() on startup.
Strict CSP. The pre-paint script is inline, which a strict script-src (no 'unsafe-inline') would block — silently reintroducing FOUC. Allow it by hash, which works for SSR and static SSG HTML (where per-request nonces are impossible):
import { themeScriptCspHash } from '@pyreon/zero'
import { cspMiddleware } from '@pyreon/zero/csp'
cspMiddleware({ directives: { scriptSrc: ["'self'", themeScriptCspHash] } })The constant is drift-locked by a test (any edit to themeScript fails CI until the hash is regenerated). For parametrized inline scripts — e.g. @pyreon/ui-core's cssVariablesPrePaintScript(opts), whose content varies with options — compute the hash with cspHashForInlineScript(script) from @pyreon/zero/csp (Web Crypto, works in Node/Bun/edge).
Middleware
Built-in server middleware. Wire them into createServer({ middleware: [...] }).
| Middleware | Import path | Purpose |
|---|---|---|
cacheMiddleware | @pyreon/zero/cache | Cache-Control headers by asset type |
securityHeaders | @pyreon/zero/cache | CSP, X-Frame-Options, etc. |
varyEncoding | @pyreon/zero/cache | Vary: Accept-Encoding for CDN correctness |
corsMiddleware | @pyreon/zero/cors | CORS |
rateLimitMiddleware | @pyreon/zero/rate-limit | Token-bucket rate limiting |
compressionMiddleware | @pyreon/zero/compression | gzip/br via native CompressionStream |
cspMiddleware | @pyreon/zero/csp | Content-Security-Policy with per-request nonce |
loggerMiddleware | @pyreon/zero/logger | Structured request logging |
import { cacheMiddleware, securityHeaders } from '@pyreon/zero/cache'
import { corsMiddleware } from '@pyreon/zero/cors'
import { rateLimitMiddleware } from '@pyreon/zero/rate-limit'
cacheMiddleware({ immutable: 31536000, static: 86400, pages: 0, staleWhileRevalidate: 60 })
corsMiddleware({ origin: ['https://app.com'], credentials: true, maxAge: 86400 })
rateLimitMiddleware({ max: 20, window: 60, include: ['/api/*'] })CSP Nonce
import { cspMiddleware } from '@pyreon/zero/csp'
import { useNonce } from '@pyreon/zero/server'
cspMiddleware({ directives: { 'default-src': ["'self'"], 'script-src': ["'self'", "'nonce'"] } })
function InlineScript() {
return <script nonce={useNonce()}>console.log('safe')</script>
}API Routes
API routes are .ts files in src/routes/api/ that export HTTP method handlers and return Response objects.
import type { ApiContext } from '@pyreon/zero'
export function GET(ctx: ApiContext) {
return Response.json([{ id: 1, title: 'Hello World' }])
}
export async function POST(ctx: ApiContext) {
const body = await ctx.request.json()
return Response.json({ id: 2, ...body }, { status: 201 })
}| File | URL |
|---|---|
src/routes/api/posts.ts | /api/posts |
src/routes/api/posts/[id].ts | /api/posts/:id |
src/routes/api/[...path].ts | /api/* (catch-all) |
Wire them via the virtual module. They run before SSR and dispatch by URL + HTTP method; unsupported methods return 405 with an Allow header. API routes also work in dev (the plugin dispatches them in the dev server).
import { routes } from 'virtual:zero/routes'
import { apiRoutes } from 'virtual:zero/api-routes'
import { createServer } from '@pyreon/zero/server'
export default createServer({ routes, apiRoutes })Using a backend proxy in dev
Vite's server.proxy works in zero dev — zero's dev middlewares yield any URL owned by a proxy context to Vite's proxy middleware instead of handling it themselves:
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero'
export default {
plugins: [pyreon(), zero({ mode: 'ssr' })],
server: {
proxy: {
// Prefix contexts and ^-RegExp contexts both work (Vite semantics).
'/api/backend': 'http://localhost:8080',
'/graphql': 'http://localhost:8080',
},
},
}When a proxy is configured, the dev server logs [Pyreon] zero dev: honoring vite server.proxy for: <contexts> on boot.
Dev request precedence: fs API routes (src/routes/api/*) > server.proxy > SSR / 404 handling. A file-system API route always wins over a proxy context covering the same path — matching production, where server.proxy doesn't exist. Unmatched /api/* requests also fall through zero's SSR/404 handling entirely (proxy or not), so user-registered dev middleware is never shadowed.
Production note: server.proxy is dev-only by Vite's design. In production, put a real reverse proxy (nginx, Caddy, your platform's rewrites) in front of the app, or expose the backend through API routes.
Server Actions
Server-side mutations callable from the client, mounted at /_zero/actions/*.
import { defineAction } from '@pyreon/zero/actions'
export const createPost = defineAction(async (ctx) => {
const { title, body } = ctx.json as { title: string; body: string }
return { success: true, id: await db.posts.create({ title, body }) }
})import { createActionMiddleware } from '@pyreon/zero/actions'
import { createServer } from '@pyreon/zero/server'
export default createServer({
routes,
middleware: [createActionMiddleware()],
})Call them from components as plain async functions: const r = await createPost({ title, body }).
The ActionContext exposes request, json (parsed JSON body), formData (for multipart/form-data), and headers.
SEO
Zero auto-generates sitemap.xml and robots.txt at build time. Declare it directly on zero() — no separate plugin import:
export default {
plugins: [
pyreon(),
zero({
seo: {
sitemap: { origin: 'https://example.com', changefreq: 'weekly', priority: 0.8 },
robots: { rules: [{ userAgent: '*', allow: ['/'] }], sitemap: 'https://example.com/sitemap.xml' },
},
}),
],
}The standalone form (import { seoPlugin } from '@pyreon/zero/seo' + a separate seoPlugin({...}) plugin entry) keeps working — zero({ seo }) is the same config object, auto-wired.
lastmod. Set sitemap: { lastmod: 'build-time' } to stamp every entry with the build date (YYYY-MM-DD), or pass an explicit ISO date. Per-entry additionalPaths[].lastmod always wins. 'build-time' is the honest automated choice — file mtimes are unreliable in CI (a git checkout stamps checkout time on every file), so "when did this deploy happen" is the strongest truthful freshness signal.
generateSitemap(paths, config), generateRobots(config), and jsonLd(data) are also exported for manual use. seoMiddleware(config) serves sitemap/robots in development. In SSG mode, the sitemap can be driven by the actual prerendered path set including dynamic and per-locale variants — see SSG → Sitemap.
Font Optimization
fontPlugin (@pyreon/zero/font) downloads Google Fonts at build time and self-hosts them; in dev it falls back to the CDN.
import { fontPlugin } from '@pyreon/zero/font'
export default {
plugins: [
fontPlugin({
google: [
'Inter:wght@400;500;700',
{ family: 'Fira Code', weights: [400, 700] },
{ family: 'Roboto Flex', weightRange: [100, 900], variable: true },
],
local: [{ family: 'Custom Font', src: './fonts/custom.woff2', weight: 400 }],
display: 'swap',
preload: true,
selfHost: true,
fallbacks: { Inter: { fallback: 'Arial', sizeAdjust: 1.07, ascentOverride: 0.9 } },
}),
],
}fontVariables({ Inter: "'Inter', sans-serif" }) generates CSS custom properties.
usePreloadFont — per-route runtime preload
For fonts that aren't in the global fontPlugin declaration — a route-specific display face, a conditionally-loaded variable font, a CDN-hosted brand font — use usePreloadFont to emit a <link rel="preload" as="font"> into <head> at render time:
import { usePreloadFont } from '@pyreon/zero'
export default function HeroRoute() {
usePreloadFont('/fonts/display-bold.woff2')
return <h1 style="font-family: 'Display Bold'">…</h1>
}Emitted tag (SSR-visible to the preload scanner):
<link rel="preload" as="font" href="/fonts/display-bold.woff2" type="font/woff2" crossorigin="anonymous">Subtleties handled automatically:
crossorigin="anonymous"is required for every font preload — even same-origin. Without it, the CSS Fonts CORS rule forces a double-fetch (preload, then refetch under CORS). The helper sets it by default.typeis required — preload-scanner ignoresas=fontwithout a matching MIME. Auto-inferred from the extension:.woff2/.woff/.ttf/.otf/.eot. Passtypeto override.Dedup — two
usePreloadFont(href)calls with the same href emit ONE preload (via@pyreon/head's LinkTag href-keying).
Use usePreloadFont per-route; for fonts declared globally via zero({ font: { google, local } }), fontPlugin already emits the preload at build time.
Image Processing
imagePlugin (@pyreon/zero/image-plugin) provides build-time image optimization via sharp (copies as-is with a warning if sharp isn't installed). Import an image with ?optimize to get a ProcessedImage ({ src, srcset, width, height, placeholder, formats }):
import { imagePlugin } from '@pyreon/zero/image-plugin'
export default {
plugins: [
imagePlugin({
widths: [640, 1024, 1920], // default
formats: ['avif', 'webp'], // default ['webp']
quality: { avif: 55, webp: 75 }, // number OR per-format object — default 80
placeholder: 'color', // 'blur' | 'color' | 'none' (default 'blur')
placeholderSize: 16, // only used by the 'blur' strategy
}),
],
}import hero from './images/hero.jpg?optimize'
import { Image, OptimizedImage } from '@pyreon/zero'
// Recommended — one prop, every field forwarded (nothing dropped)
;<OptimizedImage source={hero} alt="Hero" priority />
// Equivalent spread form
;<Image {...hero} alt="Hero" priority />Typing the ?optimize import. ?optimize (and ?component / ?raw for SVG) are custom Vite import queries — TypeScript doesn't know their shape by default. Zero ships the ambient declarations; add one line to any tsconfig-covered .d.ts (e.g. src/env.d.ts):
/// <reference types="@pyreon/zero/image-types" />This makes import hero from './x.jpg?optimize' resolve to ProcessedImage (and './logo.svg?component' to a component, './logo.svg?raw' to a string) with zero hand-authored declare module blocks — the ambient reuses the plugin's own ProcessedImage, so it never drifts.
Placeholder strategies:
| Strategy | Output |
|---|---|
'blur' (default) | Base64 blur data URI (size controlled by placeholderSize) |
'color' | The image's dominant color as a ~200-byte flat SVG data URI |
'none' | No placeholder (placeholder: '') — skips all placeholder work |
'dominant-color' is a deprecated alias of 'color'. quality accepts a single number applied to all lossy formats, or a per-format object ({ avif, webp, jpeg }) — AVIF achieves comparable perceived quality at a much lower number than WebP/JPEG. Optional CDN delivery providers (cloudinary, imgix, vercel, bunny) are also available.
Favicons
Zero generates the full favicon set from a single source (SVG or PNG) and injects every <head> tag automatically — no manual <link>/<meta> wiring. Like imagePlugin, it uses sharp for image generation.
Zero-config (file convention). Drop src/favicon.svg (or src/favicon.png) in your project and the full set is generated with defaults — nothing to configure, like Next's app/icon.png. Because you never explicitly asked, a missing sharp only warns (it never fails the build); zero({ favicon: false }) disables detection. public/favicon.svg is deliberately not detected — Vite copies public/ verbatim and the generated favicon.svg would collide with it.
Explicit config (dark variants, locales, theme colors) — declare it on zero(); with an explicit source, a missing sharp is a hard build error (you clearly wanted favicons):
export default {
plugins: [
pyreon(),
zero({
favicon: {
source: './src/assets/icon.svg', // required
darkSource: './src/assets/icon-dark.svg', // optional — light/dark variants
themeColor: '#6d28d9',
name: 'My App', // web-manifest app name
locales: { de: { source: './icon-de.svg' } }, // optional per-locale sets
},
}),
],
}(The standalone import { faviconPlugin } from '@pyreon/zero/favicon' form keeps working — same config shape.) All PNG sizes and per-locale sets are generated in parallel at build time.
Generated at build time and emitted into dist/: favicon.ico (16+32), favicon.svg, favicon-16x16.png, favicon-32x32.png, apple-touch-icon.png (180), icon-192.png, icon-512.png, site.webmanifest. The plugin injects the matching <link rel="icon"> (SVG + PNG), apple-touch-icon, manifest, and <meta name="theme-color"> into every page's <head>, plus media-conditioned light/dark links and a no-flash blocking script when darkSource is set. In dev the assets are served on the fly.
Cache-busting. Browsers cache favicons extremely aggressively (often per-session / effectively forever), so a stable URL means a changed icon is never re-fetched by returning visitors. Every injected <link> href carries a ?v=<hash> derived from the source file content — same bytes produce the identical query (no needless cache churn), changed bytes produce a new query so the browser re-downloads. This is orthogonal to theme-reactive favicons (the light/dark swap toggles the media attribute, not href). Two URLs intentionally stay stable and rely on host cache headers instead: the bare /favicon.ico (browsers request it by convention with no <link> tag) and the site.webmanifest's internal icons[] entries (re-resolved on PWA (re)install). Set a long Cache-Control on /favicon.ico at your host if you change it.
sharp is required. It is an optional peer install — add it explicitly:
bun add -D sharp # or: npm i -D sharpIn dev, a missing sharp is a one-time console warning (favicons just don't appear locally — iteration isn't blocked). In a production vite build, a configured source with sharp missing is a hard, actionable build error — the build fails rather than silently shipping a site with zero favicons. To intentionally build without favicons, remove faviconPlugin() from your Vite plugins.
Cache-busting. Browsers cache favicons extremely aggressively (often per-session / effectively forever), so a stable URL means a changed icon is never re-fetched by returning visitors. The injected <link> hrefs therefore carry a ?v=<hash> query derived from the source file content (FNV-1a): identical bytes → identical query (no needless cache churn), changed bytes → new query → the browser re-downloads. This is orthogonal to light/dark switching (the theme swap toggles each link's media attribute, not its href). Caveat: only <link>/manifest-referenced assets are versioned. The bare /favicon.ico convention request (browsers fetch it with no link tag) and the site.webmanifest's internal icon entries keep stable URLs — those rely on your host's cache headers / are re-resolved on PWA (re)install.
In dev, editing a source icon invalidates the on-the-fly cache automatically — the next request re-renders from the new bytes (no dev-server restart).
Social-Share Images (og/* zero-content: unhandled mdast node "textDirective" */)
Zero generates social-share preview images at build time from templates — a background (image file or solid color) plus positioned text layers, per locale. Declare on zero():
zero({
og: {
locales: ['en', 'de'], // optional — one image per template × locale
templates: [
{
name: 'default', // → /og/default.png (+ /og/default-de.png)
background: './src/og-bg.jpg', // or { color: '#111827', width: 1200, height: 630 }
layers: [
{
text: { en: 'Build faster', de: 'Schneller bauen' }, // string | per-locale record | (locale) => string
x: '50%', y: '45%', textAnchor: 'middle',
fontSize: 64, fontWeight: 700, color: '#ffffff',
maxWidth: 900, // word-wrap boundary (default 80% of image width)
},
],
},
],
},
})Defaults: 1200×630 (the OG-standard size), png (set format: 'jpeg' + quality for photos). All template × locale variants render in parallel at build; in dev they're generated on demand at /og/<template>[-<locale>].<ext> and the cache invalidates when a background file changes.
Wire the generated image into a page with <Meta>'s ogTemplate prop (resolves the locale-correct path for you) or the ogImagePath() helper:
import { Meta } from '@pyreon/zero'
<Meta title="Docs" description="…" origin="https://example.com" ogTemplate="default" />Font note: text layers render via SVG → sharp, which resolves fontFamily against fonts installed on the build machine (no webfont loading). Stick to widely-available families or install your brand font into the CI image.
Environment Variables
Server env — validateEnv
Server-only (reads live process.env). Types are inferred from defaults; explicit validators cover the rest.
import { validateEnv, schema, publicEnv } from '@pyreon/zero/env'
const env = validateEnv({
PORT: 3000, // number with default
DEBUG: false, // boolean with default
API_KEY: String, // required string
ALLOWED_ORIGINS: schema((v) => v.split(',')), // custom parser
})Public env — publicEnv() (works in the browser)
Prefix a variable with ZERO_PUBLIC_ to expose it to client code. publicEnv() is isomorphic — it works in server and browser code:
// .env
// ZERO_PUBLIC_API_URL=https://api.example.com
// DATABASE_URL=postgres://… ← NOT public, never reaches the client
import { publicEnv } from '@pyreon/zero/env'
const pub = publicEnv() // → { API_URL: "https://api.example.com" } (DATABASE_URL absent)Under the hood, @pyreon/zero's vite-plugin reads ZERO_PUBLIC_* from your .env* files at build time and inlines them into both the client and SSR bundles — so a value rendered during SSR matches after hydration (no mismatch), and it's available in the browser (where there is no process.env).
Security: only ZERO_PUBLIC_-prefixed vars are ever inlined. A secret without the prefix (DATABASE_URL, STRIPE_SECRET_KEY) is structurally unable to reach the client bundle — but note the prefix is the only guard, so never name a secret ZERO_PUBLIC_*.
Build-time constraint: public values are baked into the bundle at build. Changing one requires a rebuild, not just a redeploy. If you need runtime-configurable client env, fetch it (e.g. a /config.json) instead.
Validating env with any schema (zod / valibot / @pyreon/validate)
Both validateEnv and publicEnv accept any Standard Schema directly — no schema library is a dependency of @pyreon/zero; you bring your own:
import { z } from 'zod'
import { s } from '@pyreon/validate'
import { publicEnv, validateEnv } from '@pyreon/zero/env'
const env = validateEnv({
PORT: z.coerce.number().int().min(0), // raw env string → coerced number
NODE_ENV: z.enum(['dev', 'prod']),
})
const pub = publicEnv({
API_URL: z.string().url(),
ANALYTICS: s.stringbool(), // @pyreon/validate: "true"/"1"/"on" → boolean
})The raw env string is handed to the schema, so use a coercing schema for non-string values (z.coerce.number(), s.coerce.number(), s.stringbool()). Async schemas are rejected — env resolves synchronously.
Fail the build on a missing public var — zero({ env })
Declare your public env schema in the plugin config and the build fails if a declared ZERO_PUBLIC_* var is missing or invalid — so you catch it before it ships to the browser as undefined (in dev it warns instead, so an incomplete local .env doesn't block you):
// vite.config.ts
import { url } from '@pyreon/zero/env'
import { zero } from '@pyreon/zero'
export default {
plugins: [
zero({
env: {
API_URL: url(), // build FAILS if ZERO_PUBLIC_API_URL is unset/invalid
ANALYTICS_ID: String,
},
}),
],
}Keys are un-prefixed (matching publicEnv()); values are any env schema entry (a default, String/Number/Boolean, url()/oneOf(), or a Standard Schema). This is the safety net for the "works in dev, undefined in prod" trap.
App Assembly APIs
These come from @pyreon/zero/server (createApp, createServer) and @pyreon/zero/client (startClient).
createApp
Assembles router + head provider + root layout. Returns { App, router }.
import { createApp } from '@pyreon/zero/server'
const { App, router } = createApp({
routes, // from virtual:zero/routes
routerMode: 'history', // "history" (default) | "hash"
url: '/', // initial URL (SSR/SSG)
base: '/blog/', // optional — see Base Path
errorComponent: GlobalError, // optional global error boundary
})createServer
Production SSR request handler.
import { createServer } from '@pyreon/zero/server'
export default createServer({
routes,
apiRoutes, // optional — run before SSR
routeMiddleware, // optional — per-route middleware from the virtual module
middleware: [securityHeaders(), cacheMiddleware()],
config: { mode: 'ssr' },
template: indexHtml, // optional HTML template override
clientEntry: '/src/main.tsx', // optional
})startClient
Client-side hydration / mount.
import { startClient } from '@pyreon/zero/client'
startClient({ routes })startClient auto-detects whether to hydrate (SSR-rendered HTML present) or mount fresh (SPA). It also reads the Vite-injected __ZERO_BASE__ so the router prefix matches the SSR/build output. With fs-router, never pass layout to startClient.
Base Path
zero({ base: '/blog/' }) is the single source of truth for subpath deploys. It propagates to:
Vite's
base— asset URLs in built HTML/JS get the prefix (<script src="/blog/assets/...">).createRouter({ base })— RouterLink hrefs render prefixed (<a href="/blog/about">), incoming URLs arestripBased.SSG render +
startClient— the SSR sub-build and client both read__ZERO_BASE__so hydration matches.
A user's explicit vite.config.base still wins (Vite merge semantics: plugin config is the base). The on-disk dist/ layout stays unprefixed (dist/about/index.html) — the host serves dist/ mounted at /blog/.
Deployment Adapters
Adapters tailor build output per platform. Resolve from config (string name or constructed instance) or call directly. All adapter functions come from @pyreon/zero/server.
import { resolveAdapter, nodeAdapter, vercelAdapter } from '@pyreon/zero/server'
defineConfig({ adapter: 'vercel' }) // or adapter: vercelAdapter()| Adapter | SSR build | SSG build |
|---|---|---|
node | Node HTTP server output | no-op |
bun | Bun HTTP server output | no-op |
static | Static HTML/CSS/JS | no-op (dist already final) |
vercel | .vercel/output (Build Output API v3) | .vercel/output/config.json (static variant) |
cloudflare | Cloudflare Pages + Workers | _routes.json static config |
netlify | Netlify Functions (streaming) | netlify.toml / static config |
vercel/cloudflare/netlify also implement Adapter.revalidate(path) for build-time ISR — see SSG → Build-time ISR. static/node/bun implement revalidate as a no-op.
The adapter's build() receives a discriminated AdapterBuildOptions: { kind: 'ssr', serverEntry, clientOutDir, outDir, config } or { kind: 'ssg', outDir, config }. Auto-invoked by mode: SSG mode (ssgPlugin) calls adapter.build({ kind: 'ssg', … }) after every path renders; SSR/ISR modes (ssrPlugin) bundle the SSR handler to dist/server/entry-server.js and call adapter.build({ kind: 'ssr', … }). SPA mode ships only a client bundle (no adapter.build call).
SSR/ISR build
When mode: 'ssr' or mode: 'isr' is set, the build pipeline also produces a server bundle at dist/server/entry-server.js:
If you have a
src/entry-server.ts(the standard hand-written entry — useful when you shipsecurityHeaders()/cacheMiddleware()/ customssr.modeoverrides /actions: { corsOrigins }config), the plugin uses that file as the bundle entry.Otherwise the plugin synthesizes the canonical entry
import { routes } from "virtual:zero/routes"; … export default createServer({ routes, routeMiddleware, apiRoutes })automatically — no setup required.
After the bundle lands the configured adapter's build({ kind: 'ssr', … }) is invoked so platform adapters (vercel/cloudflare/netlify) can wrap it into a deployable serverless function. The recursive SSR sub-build uses PYREON_ZERO_SSR_INNER_BUILD as its env-flag gate (distinct from SSG's PYREON_ZERO_SSG_INNER_BUILD) so the two modes can never collide.
ISR Handler (runtime)
For custom runtime ISR outside mode: 'isr', use createISRHandler (@pyreon/zero/server) directly:
import { createISRHandler } from '@pyreon/zero/server'
const handler = createISRHandler(
async (req) => new Response(await renderPage(req), { headers: { 'Content-Type': 'text/html' } }),
{ revalidate: 60, maxEntries: 1000, cacheKey: (req) => new URL(req.url).pathname },
)In-memory LRU cache with stale-while-revalidate. Cached responses carry x-isr-cache: HIT|STALE|MISS and x-isr-age headers.
Two hardening knobs worth knowing:
cacheKey: 'path-only'— shorthand that keys by pathname alone, stripping every query param: the one-liner fix for analytics-param cache explosion (utm_*,fbclidwould otherwise create one cache entry per click variant). It deliberately does not count as a custom key for the auth-safety checks — responses withVary: Cookie/Authorizationare still refused unless a cacheKey function varies by user identity.expireOnTimeout: true— when a background revalidation times out, drop the stale entry so the next request renders fresh (a cache miss) instead of serving the stale copy forever. Defaultfalse(keep serving stale) is safest under load; enable when eventual freshness beats guaranteed availability for hung renders.
Note on the revalidate name: isr.revalidate is the RUNTIME stale-while-revalidate TTL; a route file's export const revalidate feeds the BUILD-TIME platform manifest (_pyreon-revalidate.json) consumed by Adapter.revalidate(). Different enforcement points, deliberately the same name — both mean "how stale may this page get".
Other Build-Time Plugins
| Plugin / API | Import path | Purpose |
|---|---|---|
faviconPlugin | @pyreon/zero/favicon | Per-locale favicon generation; faviconLinks helper |
ogImagePlugin | @pyreon/zero/og-image | Build-time Open Graph image generation |
aiPlugin | @pyreon/zero/ai | Generates llms.txt, JSON-LD inference, AI manifest |
iconsPlugin | @pyreon/zero/server | Scan an icon dir → typed <Icon name> set |
i18nRouting | @pyreon/zero/server | Request-time locale detection middleware |
import { faviconPlugin } from '@pyreon/zero/favicon'
import { ogImagePlugin } from '@pyreon/zero/og-image'
faviconPlugin({ source: './icon.svg', locales: { de: { source: './icon-de.svg' } } })
ogImagePlugin({ templates: { default: './og-template.tsx' }, locales: { en: { title: 'My App' } } })i18n
Two independent layers, both keyed off the same I18nRoutingConfig:
Request-time detection —
i18nRouting(config)Vite plugin + client hooksuseLocale()/setLocale(locale, config)(client-safe from@pyreon/zero), plus server helperscreateLocaleContext/detectLocaleFromHeader(@pyreon/zero/server).Build-time route duplication —
zero({ i18n: { locales, defaultLocale, strategy } })fans every route into per-locale variants for SSG. Documented in SSG → i18n.
import { useLocale, setLocale } from '@pyreon/zero'
function LocaleSwitcher() {
const locale = useLocale()
return <button onClick={() => setLocale('de', i18nConfig)}>{locale} → Deutsch</button>
}I18nRoutingConfig: { locales, defaultLocale, detectLocale?, cookieName?, strategy? }. Strategy is 'prefix' (every locale prefixed, including default) or 'prefix-except-default' (default — default locale keeps clean URLs).
Request Locals
Bridge middleware locals into the component tree:
import { useRequestLocals } from '@pyreon/server'
// middleware: ctx.locals.user = authenticatedUser
function Dashboard() {
const user = useRequestLocals().user as User
return <h1>Welcome, {user.name}</h1>
}Testing Utilities
From @pyreon/zero/testing:
import { testMiddleware, createTestApiServer, createMockHandler, createTestContext } from '@pyreon/zero/testing'
const { headers } = await testMiddleware(corsMiddleware({ origin: '*' }), '/api/posts')
const server = createTestApiServer([{ pattern: '/api/posts', module: { GET: () => Response.json([]) } }])
const res = await server.request('/api/posts')Subpath Exports
| Import Path | Exports |
|---|---|
@pyreon/zero | Client-safe: Image, Link, Script, Icon, Meta, island, serverIsland, activateServerIslands, theme, i18n hooks, types |
@pyreon/zero/server | createServer, createApp, defineConfig, resolveConfig, adapters, seoPlugin, aiPlugin, ogImagePlugin, iconsPlugin, i18nRouting, vercelRevalidateHandler, fs-router helpers, createISRHandler, createFsStore, createMemoryStore, default = zeroPlugin |
@pyreon/zero/client | startClient |
@pyreon/zero/config | defineConfig, resolveConfig |
@pyreon/zero/image | Image, useImage, createImage |
@pyreon/zero/link | Link, useLink, createLink, prefetchRoute |
@pyreon/zero/script | Script, useScript, createScript |
@pyreon/zero/meta | Meta, buildMetaTags |
@pyreon/zero/theme | Theme signals + ThemeToggle |
@pyreon/zero/font | fontPlugin, fontVariables |
@pyreon/zero/image-plugin | imagePlugin |
@pyreon/zero/cache | cacheMiddleware, securityHeaders, varyEncoding |
@pyreon/zero/seo | seoPlugin, seoMiddleware, generateSitemap, generateRobots, jsonLd |
@pyreon/zero/actions | defineAction, createActionMiddleware |
@pyreon/zero/api-routes | API route utilities, createApiMiddleware |
@pyreon/zero/cors | corsMiddleware |
@pyreon/zero/rate-limit | rateLimitMiddleware |
@pyreon/zero/compression | compressionMiddleware |
@pyreon/zero/csp | cspMiddleware |
@pyreon/zero/env | validateEnv, schema, publicEnv |
@pyreon/zero/logger | loggerMiddleware |
@pyreon/zero/favicon | faviconPlugin, faviconLinks |
@pyreon/zero/og-image | ogImagePlugin, ogImagePath |
@pyreon/zero/ai | aiPlugin, inferJsonLd, generateLlmsTxt, generateLlmsFullTxt |
@pyreon/zero/i18n-routing | useLocale, setLocale, buildLocalePath, extractLocaleFromPath |
@pyreon/zero/testing | Test helpers for middleware + API routes |
Next Steps
SSG reference — static generation, dynamic routes, redirects, i18n, build-time ISR, adapters, error handling.
Router — the routing primitives Zero is built on.
Create Zero — project scaffolding.