streaming-ssr

star 347

Add streaming SSR to a merjs page. Use when the user wants shell-first rendering, skeleton placeholders, or parallel data fetching that resolves inline.

justrach By justrach schedule Updated 3/15/2026

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

  1. renderStream is called instead of render when the route is hit
  2. stream.write(html) flushes bytes to the browser immediately (chunked transfer encoding)
  3. stream.placeholder(id, skeleton_html) writes a shimmer skeleton + <div id="P:id"> into the live DOM
  4. mer.fetchAll() fetches multiple URLs in parallel (threads on dev server, two-phase WASM bridge on Cloudflare Workers)
  5. stream.resolve(id, real_html) injects a hidden div + inline <script> that swaps the skeleton with real content
  6. 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

  1. 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%)} }
;
  1. Run zig build codegen to register the route
  2. 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.zigStreamWriter, fetchAll, placeholder, resolve
  • src/ssr.zig — streaming engine
  • src/router.zigdispatchStream / dispatchBuffered
  • src/worker.zig — two-phase fetch exports (collect_fetch_urls, provide_fetch_result)
  • PRIMITIVES.md — full API reference
Install via CLI
npx skills add https://github.com/justrach/merjs --skill streaming-ssr
Repository Details
star Stars 347
call_split Forks 15
navigation Branch main
article Path SKILL.md
More from Creator