Skip to main content
CMS

Serving multiple sites

Available as of version 4.2.1.

One deployment can serve many sites, each with its own content, uploads bucket and roles. That's useful when sites are created at runtime, e.g. a site per subdomain for each customer or trial, and a deploy per site isn't practical.

Each site's content is stored under Projects/<id> in Firestore, the same as a single site configured with the id option. The difference is that the site is chosen for each request instead of in root.config.ts.

Resolving the site

resolveSite(req) is called once per request and returns the site to serve. For the rest of the request, its id, name and storageBucket replace the id, name and firebaseConfig.storageBucket options, and its domain replaces domain in root.config.ts, which the CMS uses for doc URLs and live links. Everything that runs during the request picks up the site, including the CMS, its sign-in page and any RootCMSClient your routes create:

ts
// @/root.config.ts

cmsPlugin({
  // Used outside of a request, e.g. by scripts. See below.
  id: 'www',
  firebaseConfig: {
    // ...
  },
  resolveSite: async (req) => {
    const site = await lookupSite(req.hostname);
    if (!site) {
      return null;
    }
    return {
      id: site.id,
      name: site.name,
      storageBucket: site.bucket,
      domain: `https://${req.hostname}`,
    };
  },
}),

Only id is required; the other values fall back to the static options. Returning null responds with a 404.

resolveSite runs for every request the server handles, not only CMS pages, so return a site for every host you serve (including your main domain) and cache lookups that hit a database.

Creating a site

A site exists once its Projects/<id> doc has roles, the same list of roles that the Settings page edits. To create one from your own code, e.g. a sign-up flow, write the doc with the plugin's Firestore instance:

ts
const cmsPlugin = getCmsPlugin(rootConfig);
const db = cmsPlugin.getFirestore();
await db.doc(`Projects/${siteId}`).set({
  roles: {'owner@example.com': 'ADMIN'},
});

Access and storage

Sites are isolated by the same security rules as a single site. Users can only sign in to a site where they have a role, and Firestore and Storage only let them read and write that site's data.

isUserAuthorized(req, user, site) gets the resolved site as its third argument, for rules that depend on the site:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  isUserAuthorized: async (req, user, site) => {
    const account = await lookupSite(site.id);
    return !account?.expired;
  },
}),

Uploads are stored under <id>/uploads/ in the site's bucket. A bucket other than your default one needs the CMS's Storage rules too.

Scripts and scheduled jobs

Outside of a request, the static id option is used. runWithSite() runs code as a specific site, e.g. in a script that seeds a new site's content:

ts
const cmsPlugin = getCmsPlugin(rootConfig);
await cmsPlugin.runWithSite({id: 'acme'}, async () => {
  const cmsClient = new RootCMSClient(rootConfig);
  await cmsClient.saveDraftData('Pages/index', {
    meta: {title: 'Welcome'},
  });
});

The scheduled job runs for one site at a time. The /cms/api/cron.run endpoint runs it for the site its request resolves to, so schedule a call to each site's host. The cron function from @blinkk/root-cms/functions only runs it for the static id.

1
2
3
4
5
6
7
8
9
10
11
12
Breakpoint: