| name | streaming-ssr |
| description | Add streaming SSR to a merjs page. Use when the user wants shell-first rendering, skeleton placeholders, or parallel data fetching that resolves inline. |
| argument-hint | [page-name] |
| disable-model-invocation | true |
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