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:

1 Like