Add Streaming SSR to a merjs page
Scaffold or upgrade app/$ARGUMENTS.zig to use renderStream — shell-first streaming with skeleton placeholders that resolve as data arrives.
How it works
renderStream is called instead of render when the route is hit
stream.write(html) flushes bytes to the browser immediately (chunked transfer encoding)
stream.placeholder(id, skeleton_html) writes a shimmer skeleton + <div id="P:id"> into the live DOM
mer.fetchAll() fetches multiple URLs in parallel (threads on dev server, two-phase WASM bridge on Cloudflare Workers)
stream.resolve(id, real_html) injects a hidden div + inline <script> that swaps the skeleton with real content
stream.flush() ends the response
On Cloudflare Workers, mer.fetchAll() uses a two-phase JS bridge:
- Phase 1: WASM dry-run collects URLs (
collect_fetch_urls)
- Phase 2: JS fetches all in parallel via Workers native
fetch()
- Phase 3: results injected into WASM cache, full render proceeds
Steps
- Create or update
app/$ARGUMENTS.zig:
const std = @import("std");
const mer = @import("mer");
pub const meta: mer.Meta = .{
.title = "PAGE_TITLE",
.description = "PAGE_DESCRIPTION",
.extra_head = "<style>" ++ page_css ++ "</style>",
};
// Fallback for non-streaming clients (required)
pub fn render(req: mer.Request) mer.Response {
_ = req;
return mer.html("<p>Requires streaming.</p>");
}
pub fn renderStream(req: mer.Request, stream: *mer.StreamWriter) void {
const alloc = req.allocator;
// Shell hits browser immediately — before any fetch
stream.write(
\\<div class="page">
\\ <h1>PAGE_TITLE</h1>
);
// Skeleton placeholders — visible in DOM while fetching
stream.placeholder("section-a",
\\<div class="skeleton">Loading...</div>
);
// Fetch multiple URLs in parallel
const results = mer.fetchAll(alloc, &.{
.{ .url = "https://api.example.com/data-a" },
.{ .url = "https://api.example.com/data-b" },
});
defer for (results) |r| if (r) |ok| ok.deinit(alloc);
// Resolve skeleton → real content inline
if (results[0]) |res| {
stream.resolve("section-a", buildCard(alloc, res.body));
} else {
stream.resolve("section-a", "<p>Failed to load.</p>");
}
stream.write("</div>");
stream.flush();
}
fn buildCard(alloc: std.mem.Allocator, body: []u8) []const u8 {
_ = body;
return std.fmt.allocPrint(alloc, "<div class=\"card\">data</div>", .{}) catch "error";
}
const page_css =
\\.page { max-width: 640px; margin: 0 auto; }
\\.card { background: var(--bg2); border-radius: 10px; padding: 20px; }
\\.skeleton { background: var(--bg3); border-radius: 10px; padding: 20px; height: 80px;
\\ position: relative; overflow: hidden; }
\\.skeleton::after { content:''; position:absolute; inset:0;
\\ background:linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent);
\\ animation:shimmer 1.5s infinite; }
\\@keyframes shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }
;
- Run
zig build codegen to register the route
- Run
zig build serve and visit the route — watch skeletons resolve
Key rules
- MUST export both
render (fallback) and renderStream
- MUST export
pub const meta: mer.Meta
stream.placeholder(id, skeleton) must come BEFORE mer.fetchAll
stream.resolve(id, html) must come AFTER results are ready
stream.flush() must be called at the end
mer.fetchAll returns []?mer.FetchResult — always handle the null case
- Always
defer deinit on results to free memory
- The
id passed to placeholder and resolve must match exactly
Live example
See also
src/mer.zig — StreamWriter, fetchAll, placeholder, resolve
src/ssr.zig — streaming engine
src/router.zig — dispatchStream / dispatchBuffered
src/worker.zig — two-phase fetch exports (collect_fetch_urls, provide_fetch_result)
PRIMITIVES.md — full API reference
Source: justrach/merjs — distributed by TomeVault.
1---2name: streaming-ssr3description: Add streaming SSR to a merjs page. Use when the user wants shell-first rendering, skeleton placeholders, or parallel data fetching that resolves inline. Use when this capability is needed.4---56# Add Streaming SSR to a merjs page78Scaffold or upgrade `app/$ARGUMENTS.zig` to use `renderStream` — shell-first streaming with skeleton placeholders that resolve as data arrives.910## How it works11121. `renderStream` is called instead of `render` when the route is hit132. `stream.write(html)` flushes bytes to the browser immediately (chunked transfer encoding)143. `stream.placeholder(id, skeleton_html)` writes a shimmer skeleton + `<div id="P:id">` into the live DOM154. `mer.fetchAll()` fetches multiple URLs in parallel (threads on dev server, two-phase WASM bridge on Cloudflare Workers)165. `stream.resolve(id, real_html)` injects a hidden div + inline `<script>` that swaps the skeleton with real content176. `stream.flush()` ends the response1819On Cloudflare Workers, `mer.fetchAll()` uses a two-phase JS bridge:20- Phase 1: WASM dry-run collects URLs (`collect_fetch_urls`)21- Phase 2: JS fetches all in parallel via Workers native `fetch()`22- Phase 3: results injected into WASM cache, full render proceeds2324## Steps25261. Create or update `app/$ARGUMENTS.zig`:2728```zig29const std = @import("std");30const mer = @import("mer");3132pub const meta: mer.Meta = .{33 .title = "PAGE_TITLE",34 .description = "PAGE_DESCRIPTION",35 .extra_head = "<style>" ++ page_css ++ "</style>",36};3738// Fallback for non-streaming clients (required)39pub fn render(req: mer.Request) mer.Response {40 _ = req;41 return mer.html("<p>Requires streaming.</p>");42}4344pub fn renderStream(req: mer.Request, stream: *mer.StreamWriter) void {45 const alloc = req.allocator;4647 // Shell hits browser immediately — before any fetch48 stream.write(49 \\<div class="page">50 \\ <h1>PAGE_TITLE</h1>51 );5253 // Skeleton placeholders — visible in DOM while fetching54 stream.placeholder("section-a",55 \\<div class="skeleton">Loading...</div>56 );5758 // Fetch multiple URLs in parallel59 const results = mer.fetchAll(alloc, &.{60 .{ .url = "https://api.example.com/data-a" },61 .{ .url = "https://api.example.com/data-b" },62 });63 defer for (results) |r| if (r) |ok| ok.deinit(alloc);6465 // Resolve skeleton → real content inline66 if (results[0]) |res| {67 stream.resolve("section-a", buildCard(alloc, res.body));68 } else {69 stream.resolve("section-a", "<p>Failed to load.</p>");70 }7172 stream.write("</div>");73 stream.flush();74}7576fn buildCard(alloc: std.mem.Allocator, body: []u8) []const u8 {77 _ = body;78 return std.fmt.allocPrint(alloc, "<div class=\"card\">data</div>", .{}) catch "error";79}8081const page_css =82 \\.page { max-width: 640px; margin: 0 auto; }83 \\.card { background: var(--bg2); border-radius: 10px; padding: 20px; }84 \\.skeleton { background: var(--bg3); border-radius: 10px; padding: 20px; height: 80px;85 \\ position: relative; overflow: hidden; }86 \\.skeleton::after { content:''; position:absolute; inset:0;87 \\ background:linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent);88 \\ animation:shimmer 1.5s infinite; }89 \\@keyframes shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }90;91```92932. Run `zig build codegen` to register the route943. Run `zig build serve` and visit the route — watch skeletons resolve9596## Key rules9798- MUST export both `render` (fallback) and `renderStream`99- MUST export `pub const meta: mer.Meta`100- `stream.placeholder(id, skeleton)` must come BEFORE `mer.fetchAll`101- `stream.resolve(id, html)` must come AFTER results are ready102- `stream.flush()` must be called at the end103- `mer.fetchAll` returns `[]?mer.FetchResult` — always handle the null case104- Always `defer` deinit on results to free memory105- The `id` passed to `placeholder` and `resolve` must match exactly106107## Live example108109- Page: `app/stream-demo.zig`110- URL: https://merlionjs.com/stream-demo111- GitHub: https://github.com/justrach/merjs/blob/main/app/stream-demo.zig112113## See also114115- `src/mer.zig` — `StreamWriter`, `fetchAll`, `placeholder`, `resolve`116- `src/ssr.zig` — streaming engine117- `src/router.zig` — `dispatchStream` / `dispatchBuffered`118- `src/worker.zig` — two-phase fetch exports (`collect_fetch_urls`, `provide_fetch_result`)119- `PRIMITIVES.md` — full API reference120121---122> Source: [justrach/merjs](https://github.com/justrach/merjs) — distributed by [TomeVault](https://tomevault.io).123<!-- tomevault:4.0:skill_md:2026-06-23 -->