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
renderStreamis called instead ofrenderwhen the route is hitstream.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 DOMmer.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 contentstream.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 codegento register the route - Run
zig build serveand visit the route — watch skeletons resolve
Key rules
- MUST export both
render(fallback) andrenderStream - MUST export
pub const meta: mer.Meta stream.placeholder(id, skeleton)must come BEFOREmer.fetchAllstream.resolve(id, html)must come AFTER results are readystream.flush()must be called at the endmer.fetchAllreturns[]?mer.FetchResult— always handle the null case- Always
deferdeinit on results to free memory - The
idpassed toplaceholderandresolvemust match exactly
Live example
- Page:
app/stream-demo.zig - URL: https://merlionjs.com/stream-demo
- GitHub: https://github.com/justrach/merjs/blob/main/app/stream-demo.zig
See also
src/mer.zig—StreamWriter,fetchAll,placeholder,resolvesrc/ssr.zig— streaming enginesrc/router.zig—dispatchStream/dispatchBufferedsrc/worker.zig— two-phase fetch exports (collect_fetch_urls,provide_fetch_result)PRIMITIVES.md— full API reference