Server-side rendering
Server-side rendering (SSR) is the process of turning a PageRoute and
an HTTP request into an HTML response. It's the part of the framework
that runs on every page load — so it has to be fast, robust, and
predictable. It's also the part that touches both Python (the loader)
and Node.js (the React renderer), so a fair bit of the complexity
lives in the bridge between the two languages.
This doc walks through the SSR pipeline stage by stage. By the end you'll know exactly what happens between "Starlette receives a GET" and "the browser hydrates", including the worker pool protocol, the head merge algorithm, and the streaming response strategy.
Files (pyxle/ssr/):
| File | Lines | What it does |
|---|---|---|
view.py |
720 | Orchestrates loader, render, head, document assembly |
renderer.py |
310 | Wraps Node.js component rendering (subprocess or worker pool) |
worker_pool.py |
330 | Persistent SSR workers, NDJSON over stdin/stdout |
head_merger.py |
420 | Merges head elements from 4 sources, deduplicates, sanitizes |
template.py |
455 | HTML document assembly (dev + production modes) |
__init__.py |
50 | Re-exports the public surface |
The interesting code lives in view.py, renderer.py, and
head_merger.py. The rest is supporting infrastructure.
The pipeline at a glance
HTTP request
│
▼
1. Page handler (Starlette closure)
│
▼
2. build_page_response() ← view.py
│
├──▶ 3. Run @server loader
│ (import compiled module, call function, await result)
│
├──▶ 4. Resolve HEAD
│ (HEAD variable, layout JSX, page JSX, runtime registrations)
│ → merge_head_elements() in head_merger.py
│
├──▶ 5. Render React component
│ → ComponentRenderer.render() in renderer.py
│ → SsrWorkerPool.render() (or per-request subprocess)
│ → Node.js worker bundles with esbuild + React renderToString
│
├──▶ 6. Build the document shell
│ → build_document_shell() in template.py
│ → prefix + suffix HTML, props serialized as JSON
│
└──▶ 7. Stream response
→ StreamingResponse(prefix → body → suffix)
│
▼
BrowserSource: ssr/view.py:51-202.
If anything goes wrong at any stage, error handling kicks in:
- A
LoaderErrortriggers the nearesterror.pyxlboundary. - A
ComponentRenderError(the React component crashes during SSR) also tries the error boundary. - An unexpected exception falls through to
render_error_document(), which renders the developer overlay (in dev) or a genericServer Errorpage (in production).
Stage 1: importing the compiled server module
The page handler closure inside build_page_router() knows the
PageRoute for the current URL. The first thing it does is import
the compiled Python module at PageRoute.server_module_path:
spec = importlib.util.spec_from_file_location(
page.module_key, page.server_module_path,
)
module = importlib.util.module_from_spec(spec)
sys.modules[page.module_key] = module
spec.loader.exec_module(module)In dev mode, before importing, Pyxle purges any cached version of
this module from sys.modules:
if settings.debug:
_purge_page_modules(settings.pages_dir)This is the hot-reload mechanism. When you save a .pyxl file:
- The watcher rebuilds the artifacts.
- The
.pyfile on disk has new content. - The next request purges the old cached module.
- Python's import machinery reads the new file from disk.
- The new code runs.
In production mode (pyxle serve), modules are imported once at
startup and never re-imported. This avoids the per-request import
overhead and is consistent with the immutable nature of a deployed
build.
Source: ssr/view.py:614-660.
Stage 2: running the loader
Once the module is imported, Pyxle finds the function tagged with
__pyxle_loader__ = True (set by the @server decorator) and calls
it:
loader_fn = getattr(module, page.loader_name)
data = await loader_fn(request)Loaders are always async — the parser refuses to compile a sync
@server def. They take exactly one positional argument named
request (a Starlette Request object). They return a JSON-
serializable dict.
A few invariants hold:
- The loader name comes from the metadata. Pyxle doesn't
getattrfor "the function with@server" at runtime — it looked up the name once at compile time and stored it inPageRoute.loader_name. This means runtime startup doesn't have to walk the module looking for decorated functions. - The loader is awaited directly. Pyxle does not wrap it in
asyncio.shield, doesn't add timeouts, doesn't catch exceptions outside of the documented error types. The loader is your code, and it runs as your code. - The result must be JSON-serializable. If you return a
set, adatetime, or a custom class, the next stage (json.dumps) will raiseTypeError. Pyxle catches that and routes it through the same error pipeline as any other render failure.
If the loader raises LoaderError, Pyxle catches it and looks for the
nearest error.pyxl boundary up the directory tree. If found, it
renders the error page with the error context as a prop. If not, it
renders the default error document.
If the loader raises any other exception, Pyxle treats it the same way but logs it as an unexpected failure and includes the stack trace in dev mode.
Source: ssr/view.py:341-485.
Stage 3: resolving the head
The <head> of the HTML response is assembled from up to four
sources, in order of increasing priority:
- Layout
<Head>JSX blocks — collected at registry-build time fromlayout.pyxl(andtemplate.pyxl) ancestors of the current page. - The page's
HEADvariable — Python string, list of strings, or callable returning either. If it's a callable, Pyxle invokes it with the loader's data. - The page's
<Head>JSX blocks — extracted from the page's JSX section by the parser at compile time. - Runtime registrations — rare; used by some advanced helpers that register head elements during SSR.
Each source contributes a list of HTML strings (e.g.
['<title>About</title>', '<meta name="description" content="..." />']).
The merge_head_elements() function (ssr/head_merger.py:252) merges
them with deduplication and sanitization.
Deduplication
The merge identifies "the same" element by its deduplication key, which depends on the tag:
| Tag | Dedupe key |
|---|---|
<title> |
tag name (so only one <title> survives) |
<meta charset> |
"charset" (singleton) |
<meta name="X"> |
name="X" |
<meta property="X"> |
property="X" |
<meta http-equiv="X"> |
http-equiv="X" |
<link rel="canonical"> |
"canonical" (singleton) |
<link rel="X" href="Y"> |
rel="X" + href="Y" |
<script src="X"> |
src="X" |
data-head-key="X" |
X (manual key) |
| Anything else | no key — always included |
If two elements share a key, the higher-priority one wins. So a
<title> in the page's JSX overrides a <title> in the layout's
JSX. A <meta name="description"> in the page's HEAD variable
overrides one in the layout.
Within the same priority tier, deeper nesting wins (page over parent layout, child layout over root layout). This matches the behaviour of React Helmet and other head libraries — your innermost layer is the most specific, so it should win.
Source: ssr/head_merger.py:123-280.
Sanitization
Every element passes through sanitize_head_element()
(head_merger.py:197), which:
- Escapes
<and>inside<title>...</title>. The HTML spec says title content shouldn't contain raw HTML, but if a user ships dynamic title content from a loader, an attacker could try to inject a closing</title>tag and break out of the title element. Pyxle escapes both characters defensively. - Strips event handler attributes. Any attribute starting with
on(onclick,onerror,onload, etc.) is removed. Head elements don't run JavaScript, so these are pure XSS vectors. - Neutralizes
javascript:andvbscript:URLs inhref,src, andactionattributes. A<link rel="stylesheet" href="javascript:alert(1)">would actually execute that script in some browsers; Pyxle replaces the URL withabout:blank.
The sanitization is always on — there's no opt-out, even for
trusted content. If you have a legitimate need for inline script in
the head, use a <Script> component (which has its own validation
path), not a raw <script> element.
Stage 4: rendering the React component
This is the most complex part of the SSR pipeline. We need to take a React component (which is JavaScript) and produce an HTML string from it (also JavaScript), all from inside Python.
The mechanism is ComponentRenderer (ssr/renderer.py:65), which
delegates to either:
- Per-request subprocess mode (
--ssr-workers 0) — spawn a fresh Node.js subprocess for every render - Worker pool mode (default,
--ssr-workers >= 1) — keep N persistent Node.js workers alive
Both modes use the same Node.js entry script (render_component.mjs)
under the hood.
The render protocol
A render request is a JSON message:
{
"id": "uuid-...",
"componentPath": "/abs/path/to/index.jsx",
"props": {"data": {"version": "0.1.7"}}
}The Node.js worker:
- Bundles the component with esbuild. Imports get inlined into
a single JavaScript string. esbuild is fast — typically under
30ms even for non-trivial component trees. The bundle is cached
per
componentPathso repeat requests for the same page reuse the bundle. - Evaluates the bundle in a fresh context. The default export is the React component function.
- Calls
react-dom/server.renderToString. This produces a plain HTML string from the component tree. - Collects head elements emitted by
<Head>blocks. When the component renders a<Head>...</Head>block, the runtime captures the children and returns them separately (so they can be hoisted into the document head). - Replies with another JSON message.
The reply:
{
"id": "uuid-...",
"html": "<main>...</main>",
"headElements": ["<title>About</title>"],
"inlineStyles": [{"identifier": "...", "contents": "..."}]
}The id field lets the worker pool match responses to in-flight
requests, since multiple requests may be in flight simultaneously
against a single worker.
Subprocess mode
In subprocess mode, every render does:
1. Spawn `node render_component.mjs <component-path>` as a child process
2. Write the JSON request to its stdin
3. Read the JSON response from its stdout
4. Wait for the process to exitTotal cost: ~200-400ms per render, dominated by Node.js startup (loading the V8 runtime, the React module graph, esbuild's internals). This is fine for one-off scripts but unacceptable for a dev server that re-renders on every navigation.
Subprocess mode exists as a fallback for environments where keeping persistent processes alive is awkward — for example, some CI pipelines or constrained sandboxes.
Worker pool mode
SsrWorkerPool (ssr/worker_pool.py:134) keeps N Node.js
processes alive for the lifetime of the dev server. Each worker is
a long-running script that reads NDJSON requests from stdin and
writes NDJSON responses to stdout.
Pool startup:
spawn N workers
start a background reader task per worker
Render request:
pick the next worker (round-robin)
generate a request id
register a future for that id
send the request as one JSON line on stdin
await the future
Background reader (per worker):
read raw bytes from stdout
split into newline-delimited messages
for each message:
parse JSON
look up the future for the message's id
set the resultCosts per render in worker pool mode: ~30-80ms (mostly esbuild bundling). The Node.js startup is amortized across all renders the worker handles in its lifetime.
Worker pool size:
- Default: 1 worker. Sufficient for solo development.
- Recommended for production: number of CPU cores. This lets the SSR pipeline saturate the CPU under load.
--ssr-workers 0falls back to subprocess mode.
Why NDJSON?
The newline-delimited JSON protocol has three nice properties:
- It's pipe-friendly. stdin/stdout are streams; newline framing is the simplest way to split a stream into messages.
- It's debuggable. You can run a worker by hand and pipe JSON lines into it to test it without involving Pyxle.
- It interleaves cleanly. Multiple in-flight requests can share
one worker because each response carries an
idmatching its request.
The downside is that messages have to fit in memory before sending — no streaming a 100MB JSON blob through one message. In practice, SSR messages are tiny (a few kilobytes) so this isn't a constraint.
Worker crash recovery
If a worker process crashes mid-request, the pool:
- Notices the EOF on stdout.
- Marks the worker as dead.
- Rejects all in-flight futures for that worker with a clear error.
- Spawns a replacement worker to keep the pool size constant.
The replacement is silent — no log spam — unless replacements happen in rapid succession (which would indicate a deeper problem).
Source: ssr/worker_pool.py:134-330.
Why one renderer per request and a pool of workers, not the other way around?
Because rendering is CPU-bound (esbuild + React's
renderToString), not IO-bound. A single Python process can serve
many concurrent IO-bound requests using async/await, but it can't
parallelize CPU work without releasing the GIL. Offloading the CPU
work to N Node.js processes lets Pyxle saturate the CPU on
multi-core machines without the Python side getting in the way.
The Python side stays single-process and async; the Node.js side is the parallelism boundary.
Stage 5: assembling the document
build_document_shell() (ssr/template.py:67) takes the rendered
body HTML, the merged head, and the loader's data, and produces a
shell — a prefix and a suffix of HTML strings — designed for
streaming.
The shell looks like this (slightly abbreviated):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- DEV ONLY: Vite client + React Refresh preamble -->
<script type="module" src="http://127.0.0.1:5173/@vite/client" nonce="abc"></script>
<script type="module" nonce="abc">
import RefreshRuntime from "http://127.0.0.1:5173/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<!-- Global stylesheets, inlined -->
<style data-pyxle-style="...">...</style>
<!-- Merged head elements from all 4 sources -->
<title>My Page</title>
</head>
<body>
<div id="root"> ← prefix ends here
(body HTML streams in here)
</div> ← suffix starts here
<script id="__PYXLE_PROPS__" type="application/json">
{"data": {"version": "0.1.7"}}
</script>
<script nonce="abc">window.__PYXLE_PAGE_PATH__ = "/pages/index.jsx";</script>
<script type="module" src="http://127.0.0.1:5173/client-entry.js" nonce="abc"></script>
</body>
</html>A few things worth noting:
- The CSP nonce is generated per request. It's a 24-byte
URL-safe random token (
secrets.token_urlsafe(24)), attached to every<script>and<style>Pyxle emits. If you set a strict CSP header, the nonce flows through automatically. - The props are inlined as JSON. When React hydrates on the
client, it reads
<script id="__PYXLE_PROPS__">.textContent, parses it, and uses it as the initial props. This is faster than refetching the data on hydration and matches the HTML the user saw on first paint. - JSON is escaped to be safe inside
<script>. Specifically,</is escaped to<\/so a property value like"<\/script>"can't break out of the script tag and inject HTML. Source:template.py:181. - The
<div id="root">is empty in the prefix. The body HTML goes between the prefix and the suffix when the response is streamed.
Dev mode vs production mode
The shell looks slightly different in production:
- No Vite client tag. Vite isn't running.
- No React Refresh preamble. Hot reload doesn't apply in prod.
- Hashed asset paths from the manifest. Instead of
http://127.0.0.1:5173/client-entry.js, the production shell uses the entries fromdist/client/manifest.json:<link rel="stylesheet" href="/client/dist/assets/index-abc123.css" />and<script type="module" src="/client/dist/assets/index-def456.js" />. - Optional preload hints for the page's specific JS chunks.
The branching happens based on settings.debug. Source:
template.py:88-200.
Stage 6: streaming the response
Once the shell and the body HTML are ready, Pyxle wraps them in a
StreamingResponse:
async def _document_stream():
yield shell.prefix.encode("utf-8")
yield artifacts.body_html.encode("utf-8")
yield shell.suffix.encode("utf-8")
return StreamingResponse(
_document_stream(),
status_code=artifacts.status_code,
media_type="text/html; charset=utf-8",
)The browser starts receiving bytes immediately. Practical effect:
- The browser parses the
<head>and starts downloading the stylesheet and JS bundle in parallel before the body HTML has fully arrived. - Time-to-first-byte is dominated by the loader and the renderer, but time-to-first-paint is much faster than a non-streaming response would be.
What about React 18 streaming SSR?
React 18 has its own streaming SSR via renderToPipeableStream,
which can interleave Suspense boundaries with the document. Pyxle
does not use that yet — the current renderer uses
renderToString, which is synchronous on the React side.
The reason is pragmatism: renderToString is simpler, doesn't
require coordinating async boundaries between Python and Node.js,
and produces output that's compatible with React's hydration in
both v18 and the upcoming v19. Adding renderToPipeableStream would
be a future improvement; the streaming we do do (prefix → body →
suffix) is enough to get the round-trip win for the common case.
Stage 7: client-side navigation
When a user clicks a <Link href="/about">, the client runtime
intercepts the click and asks Pyxle for the next page in JSON
form instead of HTML:
GET /about
x-pyxle-navigation: 1The Starlette page handler sees the header and routes to
build_page_navigation_response() (ssr/view.py:204) instead of
build_page_response(). The nav response:
- Runs the new page's loader.
- Resolves and merges the new page's HEAD elements.
- Returns JSON:
{
"ok": true,
"routePath": "/about",
"props": {"data": {"page": "About", "subtitle": "Hello"}},
"headMarkup": "<title>About — MyApp</title><meta name=\"description\" content=\"...\"/>",
"scriptDeclarations": [],
"imageDeclarations": []
}The client runtime then:
- Updates the document
<head>by replacing the previousheadMarkupwith the new one (using small DOM patches, not a full innerHTML swap, to avoid flicker on<link>elements). - Imports the new page component dynamically (it's already in the bundle if Vite has prefetched it; otherwise the import triggers a fetch).
- Calls React's render with the new component and props.
No full page reload, no JS bundle re-download, no CSS reflash. The React tree updates in place.
Error handling
Errors at each stage are routed to a specific handler:
| Stage | Exception | What happens |
|---|---|---|
| Loader | LoaderError |
Render nearest error.pyxl with error context |
| Loader | Other exception | Render nearest error.pyxl with generic error context |
| HEAD | HeadEvaluationError |
Render error boundary or default error doc |
| Render | ComponentRenderError |
Render error boundary or default error doc |
| Anything | Other exception | Render error_document (dev) / generic 500 (prod) |
Every error path also notifies the WebSocket overlay (in dev mode) with a structured event including the route, the error, and a breadcrumb list describing which stage failed:
{
"type": "error",
"route": "/dashboard",
"error": {"type": "RuntimeError", "message": "..."},
"breadcrumbs": [
{"label": "Loader", "status": "failed", "detail": "..."},
{"label": "Renderer", "status": "blocked"},
{"label": "Hydration", "status": "blocked"}
]
}The browser overlay parses this and renders an inline error UI with the breadcrumbs as a visual stack trace.
In production (pyxle serve), error responses are intentionally
opaque: a generic <h1>Server Error</h1> page with no exception
type, no message, no route path, no Vite tag. The actual error
details are written to the server logs by the request handler. This
is a security boundary — production responses must not leak internal
state. Exception messages can contain database row IDs, internal
URLs, or file paths; a generic error page is the only safe default.
Source: ssr/template.py:250-360 (the dev/prod branching in
render_error_document).
Performance characteristics
For a typical page on a 2025-era laptop:
| Operation | Cost |
|---|---|
| Loader execution | Whatever your loader does (1-100ms typical) |
| Head merging | <1ms for typical pages (5-15 elements) |
| Worker pool render | 30-80ms (esbuild + React renderToString) |
| Subprocess render | 200-400ms (mostly Node.js startup) |
| Document assembly | <1ms |
| Streaming TTFB | Loader + render time |
The worker pool render time dominates the request lifecycle. To optimize:
- Use the worker pool, not subprocess mode. (Default since v0.1.7.)
- Set worker count to your CPU core count for production.
pyxle dev --ssr-workers 4on a quad-core gives you four-way rendering parallelism. - Cache loader results if your data changes infrequently. Pyxle doesn't have a built-in HTTP cache; use whatever caching layer fits your stack (Redis, in-memory LRU, CDN).
- Don't import heavy modules at module top level. Each hot-reload re-imports the module; deferring expensive imports to inside the loader function avoids paying the cost on every save.
Public API
The SSR module exports a small surface from ssr/__init__.py:
from .renderer import ComponentRenderer
from .view import build_page_response, build_page_navigation_response
from .view import build_not_found_responseMost code that wants to render a page calls build_page_response().
The dev server's page handler is a closure that does:
async def page_handler(request):
return await build_page_response(
request=request,
settings=settings,
page=page_route,
renderer=component_renderer,
overlay=overlay_manager,
error_boundaries=error_boundary_registry,
)Everything else flows from the PageRoute (which the dev server
built from compiled metadata) and the request (which Starlette
handed in).
Where to read next
Build and serve — How
pyxle buildproduces the same compiled artifacts that the dev server uses, but ahead of time, and howpyxle serveruns them in production without Vite.The runtime — The contract behind
@serverloaders and@actionmutations, and why they intentionally have no runtime wrapping.The dev server — How the SSR pipeline is wired into the Starlette app, including the file watcher that triggers module-cache invalidation when you save.