Skip to content

feat: enable server-side Blaze template rendering (foundation for SSG/SSR) - #507

Open
dupontbertrand wants to merge 17 commits into
meteor:release-3.1.0from
dupontbertrand:feature/static-render
Open

dupontbertrand wants to merge 17 commits into
meteor:release-3.1.0from
dupontbertrand:feature/static-render

Conversation

@dupontbertrand

Copy link
Copy Markdown

Summary

Enables Blaze.toHTML() to work on the server by making compiled templates and the Template registry available server-side. This is the foundation for SSG/SSR use cases (demonstrated in a working POC — see links below).

The key insight is that Blaze.toHTML() already works server-side through a DOM-free code path — the only problem was that compiled templates and the Template global were restricted to the client. This PR lifts that restriction without breaking anything client-side.

Changes

5 small, backward-compatible changes across 5 packages:

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

Why this is safe

  • <template> tag compiled output is pure JavaScript (Template.__checkName + Template[name] = new Template(...)). No DOM access.
  • <body> tag compiled output uses document.body — wrapped in Meteor.isClient to stay client-only.
  • Template.__checkName, Template._migrateTemplate, Blaze.Template constructor are already server-safe (no DOM dependencies).
  • DOM-related code (Template.body, HMR, _applyHmrChanges) wrapped in Meteor.isClient to remain client-only.
  • Server-side overhead: ~500 bytes per compiled template. For an app with 100 templates, ~50 KB of JS loaded at startup.

What this does NOT change

  • Client-side rendering behavior is unchanged
  • blaze, htmljs, spacebars cores are unchanged
  • The DOM-dependent code (materializer, domrange, dombackend, events, attrs) stays client-only

Proof of concept

Built on top of these changes:

Forum discussion: https://forums.meteor.com/t/ssg-ssr-for-meteor-blaze-a-proof-of-concept/64556

Testing done

  • Existing Blaze usage (client-side rendering) continues to work unchanged
  • Template.myTemplate is now accessible server-side after import '../lib/templates.html' in server/main.js
  • Blaze.toHTML(Template.myTemplate) returns proper HTML strings server-side
  • Blaze.toHTMLWithData(Template.myTemplate, data) works with data contexts
  • #each, #if, #unless render correctly through _expandView(forExpansion=true)
  • Production meteor build succeeds
  • Vanilla Blaze apps (no SSG/SSR usage) build and run identically

Consumer-side usage

Apps that want to use server-side rendering can now do:

// server/main.js
import '../lib/templates.html';  // makes Template.* available server-side

// later — pure server-side rendering
const html = Blaze.toHTMLWithData(Template.myTemplate, { title: 'Hello' });

Templates must be written as pure render functions against explicit data — no Session, no this.subscribe(), no Template.dynamic (which remains client-only).

dupontbertrand added a commit to dupontbertrand/meteor that referenced this pull request Apr 20, 2026
static-render is a server-side orchestration package that pre-renders
Blaze routes as HTML for SEO. Two modes:

- SSG (Static Site Generation): routes with static: 'ssg' are rendered
  once at server startup and cached permanently. Good for pages that
  rarely change (about, contact, terms).

- SSR (Server-Side Rendering): routes with static: 'ssr' are rendered
  on-the-fly at each request with fresh data from MongoDB via async
  staticData(). Good for product pages, articles, profiles.

Pre-rendered HTML is injected into the Meteor boilerplate via
req.dynamicBody and req.dynamicHead, so the client-side app still
loads and takes over normally (server pre-render + client takeover,
not React-style hydration).

The package auto-discovers routes from flow-router-extra (weak dep).
It also provides graceful error handling: if a template crashes
during rendering, a placeholder comment is rendered and the page
falls back to client-side rendering.

Requires the Blaze 3.1.x+ server-rendering changes (meteor/blaze#507).

Includes:
- packages/static-render/package.js, static-render-server.js, README.md
- v3-docs/docs/packages/static-render.md (full API docs)
- Sidebar entry in v3-docs/.vitepress/config.mts

Forum discussion:
https://forums.meteor.com/t/ssg-ssr-for-meteor-blaze-a-proof-of-concept/64556
@jankapunkt

Copy link
Copy Markdown
Collaborator

I would target this for release 3.2.0 to keep 3.1.0 manageable and finish it soon

@dupontbertrand

dupontbertrand commented Apr 20, 2026 •

Copy link
Copy Markdown
Author

As you want boss, to be honest it's a "small" changes but if you prefer to wait for 3.2 it's up to you, maybe you have to check with someone else because this PR have to be merged at the same time at this one meteor/meteor#14355

dupontbertrand added a commit to dupontbertrand/meteor that referenced this pull request Apr 20, 2026
static-render is a server-side orchestration package that pre-renders
Blaze routes as HTML for SEO. Two modes:

- SSG (Static Site Generation): routes with static: 'ssg' are rendered
  once at server startup and cached permanently. Good for pages that
  rarely change (about, contact, terms).

- SSR (Server-Side Rendering): routes with static: 'ssr' are rendered
  on-the-fly at each request with fresh data from MongoDB via async
  staticData(). Good for product pages, articles, profiles.

Pre-rendered HTML is injected into the Meteor boilerplate via
req.dynamicBody and req.dynamicHead, so the client-side app still
loads and takes over normally (server pre-render + client takeover,
not React-style hydration).

The package auto-discovers routes from flow-router-extra (weak dep).
It also provides graceful error handling: if a template crashes
during rendering, a placeholder comment is rendered and the page
falls back to client-side rendering.

Requires the Blaze 3.1.x+ server-rendering changes (meteor/blaze#507).

Includes:
- packages/static-render/package.js, static-render-server.js, README.md
- v3-docs/docs/packages/static-render.md (full API docs)
- Sidebar entry in v3-docs/.vitepress/config.mts

Forum discussion:
https://forums.meteor.com/t/ssg-ssr-for-meteor-blaze-a-proof-of-concept/64556
dupontbertrand added a commit to dupontbertrand/meteor that referenced this pull request Apr 20, 2026
static-render is a server-side orchestration package that pre-renders
Blaze routes as HTML for SEO. Two modes:

- SSG (Static Site Generation): routes with static: 'ssg' are rendered
  once at server startup and cached permanently. Good for pages that
  rarely change (about, contact, terms).

- SSR (Server-Side Rendering): routes with static: 'ssr' are rendered
  on-the-fly at each request with fresh data from MongoDB via async
  staticData(). Good for product pages, articles, profiles.

Pre-rendered HTML is injected into the Meteor boilerplate via
req.dynamicBody and req.dynamicHead, so the client-side app still
loads and takes over normally (server pre-render + client takeover,
not React-style hydration).

The package auto-discovers routes from flow-router-extra (weak dep).
It also provides graceful error handling: if a template crashes
during rendering, a placeholder comment is rendered and the page
falls back to client-side rendering.

Requires the Blaze 3.1.x+ server-rendering changes (meteor/blaze#507).

Includes:
- packages/static-render/package.js, static-render-server.js, README.md
- v3-docs/docs/packages/static-render.md (full API docs)
- Sidebar entry in v3-docs/.vitepress/config.mts

Forum discussion:
https://forums.meteor.com/t/ssg-ssr-for-meteor-blaze-a-proof-of-concept/64556
dupontbertrand added a commit to dupontbertrand/meteor that referenced this pull request Apr 20, 2026
static-render is a server-side orchestration package that pre-renders
Blaze routes as HTML for SEO. Two modes:

- SSG (Static Site Generation): routes with static: 'ssg' are rendered
  once at server startup and cached permanently. Good for pages that
  rarely change (about, contact, terms).

- SSR (Server-Side Rendering): routes with static: 'ssr' are rendered
  on-the-fly at each request with fresh data from MongoDB via async
  staticData(). Good for product pages, articles, profiles.

Pre-rendered HTML is injected into the Meteor boilerplate via
req.dynamicBody and req.dynamicHead, so the client-side app still
loads and takes over normally (server pre-render + client takeover,
not React-style hydration).

The package auto-discovers routes from flow-router-extra (weak dep).
It also provides graceful error handling: if a template crashes
during rendering, a placeholder comment is rendered and the page
falls back to client-side rendering.

Requires the Blaze 3.1.x+ server-rendering changes (meteor/blaze#507).

Includes:
- packages/static-render/package.js, static-render-server.js, README.md
- v3-docs/docs/packages/static-render.md (full API docs)
- Sidebar entry in v3-docs/.vitepress/config.mts

Forum discussion:
https://forums.meteor.com/t/ssg-ssr-for-meteor-blaze-a-proof-of-concept/64556
@jankapunkt jankapunkt added this to the 3.2 milestone Apr 27, 2026
@dupontbertrand

dupontbertrand commented Jul 29, 2026 •

Copy link
Copy Markdown
Author

Cross-reference so the discussion stays in one place: the sequencing question for this PR is being discussed in meteor/meteor#14355, where @nachocodoner asked for E2E coverage of the static-render package. That E2E can only assert graceful degradation until this lands and the packages/non-core/blaze submodule in meteor/meteor moves off 3.0.3 — so the 3.1.0 vs 3.2 milestone call decides it. @jankapunkt the question is waiting for you there.

I measured the rebase in the meantime, and it is small: release-3.1.0 is 2 commits ahead of the merge base, and the only real conflict is packages/templating-runtime/package.js, where the version bumps (blaze@3.0.0 → 3.1.0-alpha.0) sit adjacent to the ], 'client') line this PR removes. Resolution is "take both sides", about 6 lines. packages/templating/package.js auto-merges. I'll clear it regardless of the milestone call — it presumes nothing.

One thing to fix in the same pass: this PR adds site/source/guide/server-rendering.md without a matching entry in site/.vitepress/config.mts, so the page is unreachable from the guide sidebar.

…for all architectures

This allows compiled Blaze templates to be available on the server, which
is required for server-side rendering use cases (SSG/SSR).

Server-side overhead is minimal (~500 bytes per template). The <template>
tag compiled output is pure JavaScript with no DOM dependencies. <body>
tag compilation is handled separately in templating-tools.
The <body> tag compiled code calls Template.body.renderToDocument which
uses document.body — this crashes on the server. Wrap generateBodyJS()
output with 'if (Meteor.isClient)' to make body compiled code safe to
load on both architectures.

generateTemplateJS() is unchanged — the <template> tag compiled output
(Template.__checkName + Template[name] = new Template(...)) is already
server-safe.
The generated code calls document.body.setAttribute() which crashes on
the server. Wrap it in 'if (Meteor.isClient)' to make the compiled HTML
safe to load on both architectures.
…teor.isClient

Export Template globally (both client and server) so that compiled
templates can register themselves server-side. Previously Template was
client-only, which prevented any server-side rendering use case.

Template.__checkName is already server-safe (no DOM). Template.body,
Template.__pendingReplacement, Template._applyHmrChanges, and the HMR
branch of Template._migrateTemplate all require the DOM — wrapped with
'if (Meteor.isClient)'.

dynamic.html and dynamic.js remain client-only (they require DOM).
Matches the export change in templating-runtime. The templating package
re-exports Template from templating-runtime and was also restricting it
to the client — which would override the templating-runtime change.
…or.isClient wrap

The simpleBody helper in html-scanner-tests now expects the <body> rendering
code to be wrapped in 'if (Meteor.isClient)', matching the change in
generateBodyJS().
Document the new server-side Blaze rendering capability:
- Blaze.toHTML() / Blaze.toHTMLWithData() work on the server
- How to make templates available server-side (import in server/main.js)
- Template restrictions (no Session, no this.subscribe, no Template.dynamic)
- Link to static-render package for higher-level SSG/SSR API
- Manual integration with server-render package
- Known rspack limitation
- Add dedicated SSG subsection with about-page example
- Add dedicated SSR subsection with product-page example
- Add SSG vs SSR comparison table for decision-making
The guide page added by this PR was not reachable from the Guide sidebar.
The static-render docs live in meteor/meteor's v3-docs, not in the Blaze
site — site/source/packages/ does not exist, so the relative links resolved
to nothing. Point them at v3-docs.meteor.com instead.
The guide claimed 'Blaze 3.1.x' three times while this PR is milestoned 3.2.
Describe the capability instead and let the release notes carry the version.
meteor#14350 (enable .html imports on server config for Blaze apps) merged
on 2026-04-20, so describing the patch as still required is stale. Also drops
a link to a page that does not exist in the Blaze docs site.
static-render bails out of route discovery, path matching and regeneration
when Package['ostrio:flow-router-extra'] is absent, so without it the package
is a no-op. Point routerless setups at Blaze.toHTML() directly.
Roughly 50 of the guide's lines tutorialised static-render, a package that
lives in meteor/meteor and is not merged yet. Blaze's guide documents Blaze's
capability; the package documents its own API. Keeps the SSG/SSR distinction,
drops the duplicated route examples and comparison table.
@dupontbertrand
dupontbertrand force-pushed the feature/static-render branch from f437642 to dba6aac Compare July 29, 2026 17:06
…ring

The guide claimed that 'if you don't import .html files from your server entry
point, nothing changes'. That is false for two classes of change this PR makes
unconditionally: blaze and spacebars join the server bundle of every app using
templating, because the 'client' scoping on templating-runtime's api.use was the
only thing excluding them; and removing archMatching: 'web' compiles .html for
the server too, so an eager-loading app registers every template server-side at
startup.

Neither renders anything on its own, but both cost bundle size and startup work,
and readers deciding whether to upgrade need to know.
dupontbertrand added a commit to dupontbertrand/meteor that referenced this pull request Jul 29, 2026
render() read Template[name] outside its try/catch. Blaze only exports the
Template registry to the server from 3.1+ (meteor/blaze#507); on any released
Blaze the identifier does not exist, so that read threw a ReferenceError which
propagated through _discoverAndRender and the startup hook into boot.js's
process.exit(1) — the package crashed the app at boot instead of degrading.

Check typeof Template first and return the same placeholder-comment shape used
for a missing template.
@dupontbertrand

Copy link
Copy Markdown
Author

Pushed one more docs commit.

The guide claimed that "if you don't import .html files from your server entry point, nothing changes". That is false for two changes this PR makes unconditionally to every app using templating:

  • blaze and spacebars join the server bundle — the 'client' scoping on templating-runtime's api.use was the only thing excluding them, and it has to be lifted for the registry to exist server-side.
  • removing archMatching: 'web' compiles .html for the server too, so an app that eager-loads (no imports/ directory) registers every template on the server at startup.

Neither renders anything by itself, and rendering stays opt-in behind an explicit Blaze.toHTML() call — but both cost server bundle size and startup work, and someone deciding whether to upgrade needs that stated. The section now distinguishes explicit-imports apps from eager-loading ones.

Still missing here, and I would rather name it than leave it implicit: there are no tests for the server-availability feature itself. The only test change is a scanner expectation update. Nothing asserts that Template.foo exists on the server or that toHTMLWithData renders on the os arch. I can add those if you want them in this PR rather than a follow-up.

@dupontbertrand dupontbertrand mentioned this pull request Sep 13, 2026
15 of 17 tasks

@jankapunkt jankapunkt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dupontbertrand the code looks good to me. Would you mind adding a few SSG/SSR tests so we have this covered, too?

@jankapunkt jankapunkt modified the milestones: 3.2, 3.1 Sep 14, 2026
@jankapunkt

Copy link
Copy Markdown
Collaborator

@dupontbertrand one more thing, I moved this to 3.1 due to discussion in #492

@dupontbertrand

Copy link
Copy Markdown
Author

Thanks @jankapunkt. Just to make sure we're aligned: in #492, Nacho proposed keeping #507 for 3.2 and moving meteor/meteor#14355 to Meteor 3.7+ (comment), and I agreed. His reasoning: #507 makes blaze, spacebars and the Template runtime available on the server for every app using templating, so he'd like dedicated E2E scenarios and build/startup measurements first, while beta.0 focuses on the native-backend fixes.

Did you move it to 3.1 on purpose, meaning you'd rather ship it in 3.1.0? If so, I'll rerun the full matrix with #507 included before the beta. Otherwise it can go back to 3.2.

@jankapunkt jankapunkt modified the milestones: 3.1, 3.2 Sep 14, 2026
@jankapunkt

Copy link
Copy Markdown
Collaborator

woops sorry I misunderstood that. so I moved it back to 3.2

…plates

Compiled .html templates are now available on the server, so cover it:
static output, escaping of data in text and attributes, block helpers,
helpers with inclusion arguments, and a server-loaded file that also
contains a <body>, which must skip its client-only body code.

The first four tests run on client and server with the same expected
strings, so both sides are checked to render identically.
@dupontbertrand

Copy link
Copy Markdown
Author

@jankapunkt added in dff8e0d: server rendering tests in templating-runtime.

  • a compiled template renders to the expected HTML
  • data is escaped in text and attributes (<script> in {{title}}, " and & in an attribute), while {{{raw}}} is left as-is
  • block helpers: #if/#else, #unless, #each, #each … in, #with
  • helpers and inclusion arguments
  • a server-loaded .html file that also contains a <body> with attributes

The first four run on both client and server with the same hand-written expected strings, so they also check that both sides render identically.

How I checked that they actually cover this PR:

Run Meteor 3.4 (test-app pin) Meteor 3.5.2
New tests on release-3.1.0, without this PR 5 server tests fail (Blaze is not defined), 4 client tests pass –
Full suite with this PR, native 425 passed, 0 failed 425 passed, 0 failed
Full suite with this PR, jQuery 429 passed, 0 failed 429 passed, 0 failed
Meteor.isClient guard removed in generateBodyJS server crashes at load (reading 'addContent') –
Body-attributes guard removed in caching-html-compiler server crashes at boot (document is not defined) –

On 3.5.2, the native run uses a local copy of test-in-console without jquery, since the 3.5 driver pulls jquery in and would otherwise turn it into a second jQuery run.

Not covered on purpose: async helpers (there is no toHTMLAsync) and Template.dynamic, which stays client-only.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants