Skip to main content
CMS

Customizing the CMS

These cmsPlugin() options change how the CMS looks and behaves for your team. For every option, see CMSPluginOptions in the API reference.

Branding and themes

name is shown in the CMS header and favicon replaces the browser tab icon. minimalBranding: true hides the Root.js logo and shows the project name instead.

Themes restyle the CMS. They're experimental. A theme is a stylesheet loaded after the CMS's own, from a file relative to the project root or a css string:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  themes: [{id: 'brand', name: 'Brand', file: './cms/brand.css'}],
  defaultTheme: 'brand',
}),

Each user picks a theme under Settings → Theme, and defaultTheme applies until they do. The same page lets admins add CSS for the whole project, and anyone add CSS for themselves, without a deploy.

To write a theme, copy packages/root-cms/ui/styles/theme.css from the Root.js repo and change its values. Its --cms-* custom properties are kept stable across releases. Rules that target the CMS's class names work, but may break with any release.

Access control

Users sign in with Google, and what they can do comes from their role (see Security rules and roles). Sign-in is always required for /cms/ pages and for any page with ?preview=true, which is how the editor's preview loads draft content.

isLoginRequired(req) requires sign-in for more of your site, e.g. a staging server or an internal section. isUserAuthorized(req, user) adds your own rule for who can sign in at all:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  isLoginRequired: (req) => req.path.startsWith('/internal/'),
  isUserAuthorized: (req, user) => user.email.endsWith('@example.com'),
}),

Embedding the editor

@blinkk/root-cms/browser-client opens the CMS editor or Root AI from another site, e.g. an "Edit this page" button on a staging site. The CMS can't be framed by default, so list the embedding site in allowedIframeOrigins:

ts
// @/root.config.ts

cmsPlugin({
  // ...
  allowedIframeOrigins: ['https://staging.example.com'],
}),
ts
// On https://staging.example.com

import {RootCMSBrowserClient} from '@blinkk/root-cms/browser-client';

const cms = new RootCMSBrowserClient({cmsOrigin: 'https://example.com'});

button.addEventListener('click', () => {
  const editor = cms.openEditor('Pages/about', {deeplink: 'hero.title'});
  editor.on('published', () => location.reload());
});

openEditor() opens a pop-up by default; pass {mode: 'iframe', container} to render it in the page instead. The editor emits ready, saved, published, error and close events, and doesn't autosave. openRootAI({docId}) opens Root AI with a doc as context.

Live preview messages

Your pages can talk to the editor while they're shown in its preview pane. With RootCMSBrowserClient.connectPreview(), a page can scroll the editor to a field (for "click to edit"), highlight the field being edited, and switch the editor to another doc as the user navigates. Turn on the messages with preview.channel: true for both directions, or 'to-preview' or 'from-preview' for one.

ts
// @/root.config.ts

cmsPlugin({
  // ...
  preview: {channel: true},
}),
ts
// In your page's client-side code.

import {RootCMSBrowserClient} from '@blinkk/root-cms/browser-client';

const preview = RootCMSBrowserClient.connectPreview();
if (preview.isEmbedded) {
  // Keep the editor on the doc for the page being previewed.
  preview.navigateToDoc('Pages/about');
  // Outline the element for the field being edited.
  preview.on('highlight', ({deepKey, scroll}) => {
    // ...
  });
  // Click an element to edit its field.
  heroTitle.addEventListener('click', () => preview.focusField('hero.title'));
}

Outside the preview pane, isEmbedded is false and the calls do nothing, so the code is safe to ship. navigateToDoc() asks the editor to confirm before switching docs; pass {confirm: false} to switch straight away.

Image serving

By default, uploaded images are served straight from Cloud Storage. With gci: true, uploaded images are served from a Google image-serving URL instead, which can resize and convert them on the fly. It uses the hosted service at services.rootjs.dev; set gci to a URL to use your own. The storage bucket needs fine-grained permissions, and the service's account (listed at https://services.rootjs.dev/_/service_account) needs owner access to it.

Search and the dependency graph

The CMS's global search indexes every doc, and the index is kept up to date by the scheduled job. searchIndex limits what's indexed, with includeCollections, excludeCollections, includeDocIds and excludeDocIds (doc ids look like Pages/about).

The dependency graph tracks which docs reference which, through schema.reference() and schema.references() fields. It's on by default and also kept up to date by the scheduled job. Use it to fetch everything a page needs in one batch request:

ts
const depIds = await cmsClient.getDocDependencies(['Pages/index'], {
  mode: 'published',
});
const req = cmsClient.createBatchRequest({mode: 'published'});
req.addDoc('Pages/index');
depIds.forEach((docId) => req.addDoc(docId));
const res = await req.fetch();

Dependencies are followed transitively unless you pass transitive: false. dependencyGraph: false turns the graph off, and {includeCollections, excludeCollections} limits which collections are scanned for references.

Other options

  • defaultOneOfVariant: 'dropdown' (the default) or 'picker', the default UI for oneOf fields. A field's own variant overrides it.

  • logLevel: how much the CMS logs to the console, from 'debug' to 'silent'. Defaults to 'info'.

  • watch: set to false to stop the dev server from watching schema files and regenerating root-cms.d.ts.

  • firebaseConfig.databaseId: use a named Firestore database instead of the default one.

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