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 ![]()
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'orstatic: 'ssr'viaFlowRouter._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-chairand 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
.htmlimports 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()andstaticHead()both query MongoDB separately. IdeallystaticHead()should receive the resolvedstaticData()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
- Would the community want this for Blaze apps?
SEO has been a long-standing pain point: is this the right approach? - Should the rendering orchestration live in core or as an Atmosphere package?
- 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 ![]()