Simple isomorphic React SSR for Meteor with subscribed data re-hydration
This project, like all of the projects maintained by the Meteor Community Packages org, takes time and hard work to keep updated. If you find this or any of our other packages useful, consider visiting the sponsor section of a repo and sending some love to the dedicated developers that keep your favorite packages up to date.
-
First install NPM dependencies
npm install --save react@19 react-dom@19 react-router@7
React 19 and React Router 7 or 8 are required. Your app imports React Router and passes it to this package (see Usage), so React Router 7 or 8 both work — including v8's ESM-only build. This package renders and hydrates the whole document and relies on React 19's native document-metadata hoisting for the
<head>(see Managing the document head); it will refuse to load on React 18, andreact-helmetis neither needed nor supported. (For React Router 6, usereact-router-ssr@6.) -
Install
communitypackages:react-router-ssrmeteor add communitypackages:react-router-ssr
⚠️ package.jsdeclaresapi.versionsFrom('METEOR@3.0.1'), but that floor does not work — and has not since before 7.1.0. On METEOR@3.0.1 (webapp 2.0.4)renderWithSSRrenders no app markup at all; the test suite reports 23 passing / 50 failing there on 7.1.0, and 5 passing / 68 failing on 7.0.1.Verified working: Meteor 3.4.1 (webapp 2.1.2) and Meteor 3.5 (webapp 2.2.0) — the suite is 80 passing on both. The true minimum is somewhere above 3.0.1 and at or below 3.4.1; it has not been pinned down because the intermediate releases could not be built for testing. If you are on 3.1–3.3, test before relying on it. The declared floor is left unchanged rather than raised to a number that is equally unmeasured.
v7 adds React Router 7 and 8 support (v6 supported React Router 6) and changes how React Router is supplied to the package — your app now injects it. There are three changes:
-
Move to React Router 7 or 8 and update your app's own imports from
react-router-domtoreact-router(v7/v8 consolidated everything into thereact-routerpackage):npm uninstall react-router-dom npm install --save react-router@7 # or react-router@8 -
Pass React Router into
renderWithSSR. The package no longer imports React Router itself — you inject it. This is what makes React Router 7/8 (including v8's ESM-only build) work under Meteor's package build stack, and it guarantees a single shared React Router instance:+import * as ReactRouter from "react-router"; -renderWithSSR(AppRoutes); +renderWithSSR(AppRoutes, { reactRouter: ReactRouter });
-
Remove the Rspack externals config. If you added
compileWithMeteor([...])for react-router torspack.config.js(required in v6), delete it — with injection there is one shared React Router instance and nothing to externalize. No bundler configuration is needed at all.
If you are still on React Router 6, stay on react-router-ssr@6.
renderWithSSR(routes, { reactRouter }) - Isomorphic app rendering. Renders and hydrates
the whole <html> document, so there is no mount element to configure.
-
routes- A JSX element or array of JSX elements that represent the routes of your app. -
reactRouter(required) - Your app's React Router module, i.e. the result ofimport * as ReactRouter from "react-router". The package uses the router primitives from this module rather than importing React Router itself (see Usage for why).import * as ReactRouter from "react-router"; import { renderWithSSR } from "meteor/communitypackages:react-router-ssr"; const AppRoutes = [ { path: "/", element: <Home /> }, { path: "/about", element: <About /> }, ] renderWithSSR(AppRoutes, { reactRouter: ReactRouter });
useSubscribeSuspense(name, ...args) - A server enabled version of react-meteor-data's suspendable useSubscribe hook. Arguments are same as Meteor.subscribe.
requestRoutedUrl(req) (server only) - The URL the renderer will route on for a given
request. See below.
Returns the WHATWG URL that this
package hands to React Router for req. Server only. Does not throw for any input.
import { WebApp } from "meteor/webapp";
import { requestRoutedUrl } from "meteor/communitypackages:react-router-ssr";
WebApp.handlers.use((req, res, next) => {
// pathname/search come from the request target and are trustworthy.
// The ORIGIN comes from the client's Host header and is NOT — see below.
const url = requestRoutedUrl(req);
if (url.pathname.startsWith("/admin") && !isAdmin(req)) {
res.writeHead(302, { Location: "/login" }); // a path, not url.origin + …
res.end();
return;
}
next();
});It accepts both request shapes:
- a raw connect/express request, as your middleware sees it — webapp has not categorized it
yet, so the helper reproduces categorization itself (dropping the
#fragmentand stripping a leading/__<arch>segment); - an already-categorized webapp request, as passed to boilerplate data callbacks.
| part | trust | |
|---|---|---|
pathname |
trustworthy | may legitimately begin with // — see below |
search |
trustworthy but normalized | a re-serialization, not raw bytes — see below |
origin, host, protocol, href |
not trustworthy | derived from client headers — see below |
search is a re-serialization, not the request's query string. It is rebuilt from the
query object webapp parsed, so ?a=b%20c&flag comes back as ?a=b+c&flag=. Repeated keys are
lossy, in a webapp-version-dependent way: ?a=1&a=2 becomes ?a=2 on webapp 2.2.0 (which
builds the object with Object.fromEntries) but ?a=1%2C2 on 2.1.2 (which comma-joins). That
is faithful to what the renderer routes on — which is the whole point of this helper — but
never recompute a signature or HMAC over it; read req.url if you need the raw bytes.
pathname is trustworthy. origin, host and href are not.
This package deliberately derives the origin from the request's Host and X-Forwarded-Proto
headers, because host-routed multi-tenant apps have to be able to see which host was asked
for. Since 7.1.0 those headers can no longer inject a path (see the
changelog), but a syntactically valid host is still taken at face value:
GET /events/e1 HTTP/1.1
Host: evil.examplegives a URL whose host is evil.example — and the same value reaches request.url inside
your React Router loaders and actions. Treat it as attacker input:
- Do not redirect to an absolute URL built from it. React Router's common idiom
redirect(new URL("/login", request.url))becomes an open redirect to the attacker's host. Redirect to a path —redirect("/login")— or pin the origin explicitly withnew URL("/login", Meteor.absoluteUrl()), or check the host against an allow-list first. - The same applies to canonical
<link>tags,og:url, absolute asset URLs, signed callback URLs and anything else derived from the origin. pathnamecan begin with//.GET //evil.example/xlegitimately routes withpathname === "//evil.example/x", so redirecting to a bareurl.pathnameyields a protocol-relative open redirect. Prefix-check or normalise before using it as aLocation.
If your app is not host-routed, the simplest rule is to ignore the origin entirely and build
absolute URLs from Meteor.absoluteUrl().
Two more things worth knowing when you assess exposure:
X-Forwarded-Hostis not consulted, anywhere. The origin comes fromHostonly. HonouringX-Forwarded-Hostwould hand a second, even less constrained header control of the origin, so it is deliberately ignored — but that means host passthrough only works if your proxy rewritesHost. Behind a proxy that keeps its ownHostand forwards the original inX-Forwarded-Host, your app sees the proxy's internal authority, silently. If you need that value, read the header yourself and check it against your own allow-list.- The scheme comes from
X-Forwarded-Protowhen it ishttporhttps, and otherwise from your app'sROOT_URL— not from a hardcodedhttp. An https app behind a terminator that sets noX-Forwarded-Prototherefore still getshttps://URLs.
- Scheme — the first comma-separated hop of
X-Forwarded-Protoif it is exactlyhttporhttps; otherwiseROOT_URL's scheme; otherwisehttp. - Host — the
Hostheader, if it is a plausible authority andnew URL()accepts it. "Plausible" is a conservative whitelist: a registered name of unreserved characters only (A–Z a–z 0–9 . _ ~ -) or a bracketed IPv6 literal, with an optional numeric port. It admits none of/ \ # ? @, whitespace or control characters — and, erring on the safe side, it also rejects sub-delims, percent-encodings and IPv6 zone identifiers, which are technically legal in an authority. Anything it rejects falls back toROOT_URL's host, and finally tolocalhost.
The path and query are then applied with the URL object's pathname/search setters, never
by string concatenation, so no part of the path can reach the authority.
Deriving the routed URL looks like two lines of string handling, and it is not. It has to pick
the pathname out of three different request shapes webapp has used over time, reproduce
webapp's own categorization, keep the query string, and — most importantly — refuse to let the
client-supplied Host and X-Forwarded-Proto headers put a path into the URL. Getting that
last part wrong is not a cosmetic bug: it lets any client choose which route your server
renders (this package shipped exactly that bug through 7.0.1; see the
changelog).
A hand-written copy in an app also drifts. If your middleware decides on one pathname and the
renderer routes another, you get authorization checks and redirects that apply to a different
URL than the one that is actually rendered — a class of bug that survives code review because
both halves look correct in isolation. createFetchRequest inside this package calls
requestRoutedUrl too, so calling it from your app is the only way to be sure you are asking
the same question the renderer answers.
This package renders and hydrates the entire <html> document — it produces its own
<html>, <head>, and <body>. You do not need a mount element (<div id="…">) or a
hand-written <head> in a static HTML file; anything you put there is replaced on hydration.
Configure the <head> from your components instead — see
Managing the document head.
Import React Router in your app and pass it to renderWithSSR. Call it from shared code,
such as a /both/main.jsx file, or a file imported into your mainModule for both the client
and server.
import * as ReactRouter from "react-router";
import { renderWithSSR } from "meteor/communitypackages:react-router-ssr";
import React from "react";
import DashboardPage from "./imports/ui/pages/dashboard";
import ProfilePage from "./imports/ui/pages/profile";
import LoginPage from "./imports/ui/pages/login";
const AppRoutes = [
{ path: "/", element: <DashboardPage /> },
{ path: "/profile/:username", element: <ProfilePage /> },
{ path: "/login", element: <LoginPage /> },
];
// Alternatively you can use a JSX fragment
// const AppRoutes = (
// <>
// <Route path="/" element={<DashboardPage />} />
// <Route path="/profile/:username" element={<ProfilePage />} />
// <Route path="/login" element={<LoginPage />} />
// </>
// );
renderWithSSR(AppRoutes, { reactRouter: ReactRouter });Meteor packages are compiled by Meteor's package build stack, which cannot consume React Router
7/8's ESM (it uses import.meta). Your app's code, however, is bundled by your app's bundler
(e.g. Rspack), which handles it fine. So instead of importing React Router itself, this package
takes the module you import in your app. A useful side effect: there is then only ever one
React Router instance (your app's), shared with the package by reference — so there is no
duplicate-context problem and no bundler externals configuration is required.
This package renders the entire <html> document and hydrates it with
hydrateRoot(document, …). It deliberately does not render a <title> or
any <meta> tags of its own — instead it relies on
React 19's native document metadata support:
any <title>, <meta>, or <link> you render from a route or component is
hoisted into <head> automatically, on the server and on the client. This
replaces react-helmet entirely.
Just render the tags where it's convenient — typically at the top of each page component:
function ProfilePage() {
const { username } = useParams();
return (
<>
<title>{`${username} · MyApp`}</title>
<meta name="description" content={`Profile page for ${username}`} />
{/* …page content… */}
</>
);
}During SSR the correct title/meta are streamed into the served HTML, and on client-side navigation React updates them as the matched route changes — no extra library or provider required.
None is required. Because your app imports React Router and passes it in (see Usage), there is a single React Router instance shared between your app and this package, so there is nothing to deduplicate or externalize — this works with Meteor's Rspack bundler and the classic bundler alike.
Upgrading from v6? v6 required a
compileWithMeteor([...])externals block inrspack.config.jsto share a single react-router copy. With injection that's no longer needed — delete it.
If your app's CSS goes through Rspack (e.g. Tailwind via a postcss-loader rule, as in
Meteor's --tailwind skeleton), the Rspack integration delivers the compiled stylesheet as a
<link> in the boilerplate head fragment, which it contributes through static-html. This
package carries those links into the rendered document (since 7.0.1), but the fragment has to
exist for that to work: keep the static-html package and a client/main.html — an empty
<head></head> is enough. Everything else in that file is replaced by the rendered document,
so don't put content there; manage the head from your components instead (see
Managing the document head).
The suite lives in a small Meteor application under tests/app/, which resolves this package
from the checkout through the symlink at tests/app/packages/react-router-ssr. meteor test-packages cannot be used here: React Router 7/8 is not (and deliberately cannot be) an
Npm.depends of this package, so the tests need a real app to inject it.
cd tests/app
meteor npm install # once
meteor npm testThat runs:
TEST_CLIENT=0 TEST_SERVER=1 meteor test --full-app --once \
--port 3737 --driver-package meteortesting:mochaEvery test starts from a real socket, because the bugs they cover are invisible to a unit test
of an exported function. Hostile requests are written as raw bytes, since fetch and
http.request both refuse to send a Host header containing /.
Most tests then go all the way through webapp → renderWithSSR → React Router and assert on
the route that actually matched. The requestRoutedUrl tests are different: they call the
export from WebApp.handlers middleware — the way a consumer does — and terminate there
without reaching the renderer. A dedicated cross-check drives both paths for the same targets
and asserts they agree on pathname, search, origin and href, which is what makes the middleware
tests meaningful.
Note: a Meteor boot failure prints
0 passingwith no failures, which reads as green. Always check the count — as of 7.1.0 the suite is 80 passing.
-
Nothing on the client.
TEST_CLIENT=0is hard-coded in the script, so hydration,hydrateRoot(document, …), client-side navigation and theuseSubscribeSuspenseclient path are all unexercised;TEST_CLIENT=1would need a browser driver package that is not installed. Server rendering, request handling and URL derivation are covered; hydration is not. -
Only recent Meteor releases. The suite is run on Meteor 3.5 (webapp 2.2.0) and Meteor 3.4.1 (webapp 2.1.2) — 80 passing on both, which is what gives the webapp-version-dependent query behaviour described above real coverage.
⚠️ package.jsdeclaresapi.versionsFrom('METEOR@3.0.1'), and that floor does not work. On METEOR@3.0.1 (webapp 2.0.4)renderWithSSRrenders no app markup at all: the suite reports 23 passing / 50 failing on 7.1.0, and 5 passing / 68 failing on 7.0.1, so this predates 7.1.0 rather than being caused by it. The true minimum lies somewhere above 3.0.1 and at or below 3.4.1; it has not been pinned down, because the intermediate releases could not be built in this environment. Treat 3.4.1 as the lowest verified release.
meteor test and a running dev server cannot share the app directory, so stop one before
starting the other.