SSG & SSR for Meteor + Blaze: A Proof of Concept

Hey guyyyyyysss,

I built a proof of concept showing that Blaze can support both SSG and request-time SSR with surprisingly small changes to the existing stack.

Live demo: https://ssg-ssr-demo.sandbox.galaxycloud.app
Source: GitHub - dupontbertrand/meteor-ssg-ssr-demo: Meteor 3.4 + Blaze demo: SSG, SSR, and reactive DDP rendering modes side by side · GitHub


Why this matters

Meteor + Blaze apps usually ship very little meaningful HTML in the initial response so most of the page arrives later via JavaScript, DDP, and subscriptions. This makes SEO less robust, social link previews unreliable, and crawler indexing less predictable compared to SSG/SSR-first frameworks

React-based Meteor apps can work around this with server-render + renderToString. Blaze has no equivalent: the existing third-party option (meteorhacks:ssr) is unmaintained and incompatible with Meteor 3 :grimacing:

The key finding

Blaze.toHTML() already works on the server through a DOM-free code path

The _expandView(forExpansion=true) path in view.js never touches the DOM. It calls _createView, then view._render(), then recursively expands sub-views — all through pure JavaScript. The DOM-dependent code (materializer.js, domrange.js, dombackend.js) is never invoked.

The only problem is that compiled templates aren’t available on the server. The template compiler restricts output to the client (archMatching: 'web'), and Template isn’t exported to the server.

What this appears to require

5 relatively small and backward-compatible changes in Blaze packages:

Package Change
templating-compiler Remove archMatching: 'web' so templates compile for all architectures
templating-tools Wrap generateBodyJS() with if (Meteor.isClient) — body rendering uses document.body
caching-html-compiler Wrap body attribute code with if (Meteor.isClient)
templating-runtime Export Template to server, guard DOM code with if (Meteor.isClient)
templating Export Template to server

The compiled output for <template> tags is already server-safe — just Template[name] = new Template(...). Only <body> tags needed guards. Server-side overhead is minimal (~500 bytes per template, ~50 KB for 100 templates).

1 small orchestration package (static-render, ~280 lines):

  • Auto-discovers routes with static: 'ssg' or static: 'ssr' via FlowRouter._routes
  • SSG: pre-renders at startup, serves from an in-memory cache
  • SSR: renders at each request with fresh MongoDB data via async staticData()
  • Injects content into the Meteor boilerplate via existing req.dynamicBody / req.dynamicHead
  • Graceful error handling inspired by blaze#481

What stays unchanged: blaze core, htmljs, spacebars, webapp, flow-router-extra.

The developer API

// SSG — rendered once at startup, cached
FlowRouter.route('/about', {
  static: 'ssg',
  template: 'about',
  staticData() {
    return { title: 'About Us', description: '...' };
  },
  staticHead() {
    return '<title>About | MyShop</title>' +
      '<meta name="description" content="...">' +
      '<link rel="canonical" href="https://myshop.com/about">';
  },
});

// SSR — rendered at each request with fresh data
FlowRouter.route('/products/:slug', {
  static: 'ssr',
  template: 'productPage',
  async staticData(params) {
    return await Products.findOneAsync({ slug: params.slug });
  },
  // Current POC API; ideally staticHead would receive resolved staticData
  async staticHead(params) {
    const p = await Products.findOneAsync({ slug: params.slug });
    return `<title>${p.title} — $${p.price} | MyShop</title>` +
      `<meta name="description" content="${p.description}">` +
      `<script type="application/ld+json">${JSON.stringify({
        '@context': 'https://schema.org', '@type': 'Product',
        name: p.title, offers: { '@type': 'Offer', price: p.price, priceCurrency: 'USD' }
      })}</script>`;
  },
});

Server-rendered templates must avoid client-only APIs and rely on explicit data context — no Session, no this.subscribe(), no Template.dynamic.

Server entry point just needs explicit imports:

import '../lib/templates.html';
import '../lib/routes.js';

What the demo proves

The demo app at https://ssg-ssr-demo.sandbox.galaxycloud.app has three sections:

  • /about, /contact (SSG) — pre-rendered at startup, full HTML in View Source
  • /products/oak-chair and 4 other products (SSR) — rendered per request with MongoDB data, complete with <title>, meta tags, canonical URL, and JSON-LD Product structured data
  • /stocks (standard Meteor) — real-time reactive updates via DDP, no server rendering

On product pages, a split layout lets you edit the description, save to MongoDB, then refresh to see the updated content in the server-rendered HTML. This contrasts with the stocks page where updates are instant (no refresh needed) — showing clearly when you’d use SSR vs standard Meteor reactivity.

This is not primarily a performance story — TTFB is similar across modes (~330-340ms, dominated by network latency). The main value is HTML completeness at first response for SEO and sharing.

Important: this is not React-style hydration. The server pre-renders content into the initial HTML, then client-side Blaze takes over normally and the pre-rendered placeholder is removed during startup. It’s server pre-render + normal client takeover.

Limitations and open items

  • Template restrictions: server-rendered templates must avoid client-only APIs and Tracker-based reactivity. Explicit staticData() provides the data context.
  • Rspack: this POC works with the classic Meteor build, but not yet with Rspack, because server-side .html imports are not currently handled the same way. This likely needs coordination with the Rspack integration.
  • Head escaping: the POC uses string concatenation for staticHead(). A production package should provide proper escaping helpers.
  • Duplicate queries: in the current API, staticData() and staticHead() both query MongoDB separately. Ideally staticHead() should receive the resolved staticData() result to avoid duplicate queries.
  • Route safety: this assumes route definitions are safe to evaluate on the server.

So the main takeaway from this POC is: Blaze server rendering appears to be much closer than it might seem, and most of the work is integration rather than inventing a new rendering model.

Questions for the community

  1. Would the community want this for Blaze apps? :man_shrugging: SEO has been a long-standing pain point: is this the right approach?
  2. Should the rendering orchestration live in core or as an Atmosphere package?
  3. How important is Rspack support for real adoption? Should we block on it or ship without and address it separately?

Looking forward to feedback. The demo is live and the full source is on GitHub :+1:

3 Likes

Solid work and write up. As for the first question, I’d say yes, absolutely. I’m still a big fan of Blaze and use it in my app https://honoringthem.com/. To get the site to a better place with respect to SEO, I’m using a combination of sink.appendToBody(html) and https://ostr.io for pre-rendering. It’s working for the most part, but feels hacky and a bit untenable to maintain as the app grows.

1 Like

would love this. I am using sink.appendToBody(html) as well for titles and meta tags and render them via express for link previews and the like.

true SSR would be amazing

Update: Rspack compatibility for Meteor SSG/SSR is a one-rule fix

Following up on my previous post about SSG & SSR for Meteor + Blaze, I flagged Rspack incompatibility as the main limitation. I investigated further, and it turns out the fix is a single rule added to @meteorjs/rspack/rspack.config.js, mirroring what already exists on the client side.

Live side-by-side demos:

Both serve identical SSG/SSR content and SEO metadata. Same app, same features, same Meteor version — only the build system differs.

Why it was failing

On the client, rspack handles .html files via two mechanisms working together:

  1. An ignore-loader rule that tells rspack not to try to parse .html as JavaScript
  2. The RequireExternalsPlugin that marks .html imports as externals and writes require() calls resolved by Meteor’s module system to the JS produced by the template compiler

On the server, only the second piece is in place. The ignore-loader rule is missing, so when server/main.js does import '../lib/templates.html', rspack tries to parse the raw HTML as JavaScript and crashes.

The fix

In @meteorjs/rspack/rspack.config.js, around line 596, the server config currently has:

module: {
  rules: [swcConfigRule, ...extraRules],
  ...
}

The fix is to make it symmetric with the client config (which already does this at line ~498):

module: {
  rules: [
    swcConfigRule,
    ...(Meteor.isBlazeEnabled
      ? [{ test: /\.html$/i, loader: 'ignore-loader' }]
      : []),
    ...extraRules,
  ],
  ...
}

That’s it. One block, guarded by Meteor.isBlazeEnabled, so zero impact on non-Blaze apps.

Testing

I validated the fix across several scenarios, including a full deployment on Galaxy:

Scenario Result
SSG route /about Pre-rendered HTML + meta tags in initial response
SSR route /articles/:slug with MongoDB data Fresh data served per request
SSR data freshness (update DB, refresh) New content served immediately
DDP WebSocket (/sockjs/info) Works normally
Meteor methods (callAsync) Works normally
Client-side HMR Rebuilds cleanly, no crash
Meteor reactive page with {{#each}} + subscription Works normally
meteor build (production) Compiles successfully
Vanilla Blaze app with rspack (no SSG/SSR) No regression
React app with rspack (isBlazeEnabled = false) Rule is inert, no impact
Galaxy deployment (rspack build) Deployed and serving SSG/SSR content

The compiled server bundle contains the expected Template[...] registrations, and the static-render package successfully discovers and renders routes at startup. View Source on the rspack demo shows identical SEO output (canonical URLs, og tags, JSON-LD Product data).

How to apply the patch in an app today

The rspack demo uses a postinstall script that patches node_modules/@meteorjs/rspack/rspack.config.js automatically. See scripts/patch-rspack.js. This is a workaround for the demo — the real fix belongs upstream in @meteorjs/rspack.

What this unlocks

With this fix merged, SSG/SSR for Blaze works with the modern Meteor stack (rspack + Meteor 3.4) without requiring users to opt out of rspack.

Combined with the 5 small Blaze package changes and the static-render orchestration package, the full path to SSG/SSR for Meteor + Blaze is now:

  • Blaze: remove archMatching: 'web' from templating-compiler, guard DOM code with Meteor.isClient in a few places, export Template to server
  • Rspack: add the ignore-loader rule to the server config (this PR)
  • Meteor app: add the static-render package (either core or Atmosphere)

Open questions

  1. The fix is in @meteorjs/rspack (npm package), not in the Meteor monorepo. Where should the PR land? Is there a tracked source repo for @meteorjs/rspack?
  2. Should this ship independently, or wait for the Blaze changes to be merged first? Technically it doesn’t depend on them — it just makes .html imports not crash on the server, which is arguably correct behavior on its own.

Links

Happy to submit the PR to whichever repo hosts the @meteorjs/rspack source. Any inputs welcome :+1:

3 Likes

@meteorjs/rspack lives in the Meteor core repo under npm-packages, like the other @meteorjs scoped npm packages.

Feel free to open a PR to enable this option, so Meteor SSR has support with Blaze by default. :rocket:

2 Likes