This guide covers how to set up a new brand environment from scratch. When a brand user logs in, the system automatically loads their theme, swaps the logo, updates the home link, and filters the sidebar navigation.
Scope. This walkthrough is for brand instances hosted inside BrandOS — auth-driven theming on the docs site. A standalone consumer site (like the studio site) doesn't use brand instances: it loads one theme.css of its own after the engine CSS. See Setup for that model.
A brand instance is two files plus assets:
| File | Holds | Owner |
|---|---|---|
cms/brands/<brand>/brand.json |
Admin settings: name, description, Google Fonts URL, footer text, optional favicon and OG image paths | Set at onboarding, rarely touched |
cms/brands/<brand>/assets/theme.css |
Designer settings: font slots and roles, palette, semantic colours, dark mode | Iterated during design |
The generator reads every manifest and builds assets/js/theme-config.js from them on each build. That file is generated output. Never edit it by hand.
By Default itself runs on the same model: instance 0's manifest and theme live at cms/brands/bydefault/, and the engine (design-system.css) ships neutral defaults.
What Brands See
When a brand logs in, the following elements personalise automatically:
| Element | Default (team/admin) | Brand |
|---|---|---|
| Logo | By Default | Brand logo from brand-name/assets/ |
| Home link | index.html |
brand-name/index.html |
| Theme | By Default (instance 0's theme.css) |
Brand theme.css |
| Fonts | Zalando Sans + ES Face (instance 0's theme.css) |
Per the brand's theme.css and brand.json fonts URL |
| Sidebar | Full navigation | Auto-filtered by page access |
All of this is driven by the user's brandFolder value in Netlify Identity.
Quick Checklist
- Copy
cms/brands/brand-template/tocms/brands/your-brand/ - Edit
cms/brands/your-brand/brand.json: name, description, fonts URL, footer - Update
cms/brands/your-brand/_defaults.mdwithaccess: "brand:your-brand" - Replace the demo logos in
cms/brands/your-brand/assets/with the brand's - Customise
cms/brands/your-brand/assets/theme.css - Grant tool access: add
your-brandtoactionAccessin relevantcms/*.mdfiles - Create a Netlify Identity user with the brand role
- Run
cd cms/generator && npm run docgenand test in dev mode
There is no separate registration step. A folder with a brand.json and an assets/theme.css is a live brand: the generator adds it to the runtime registry, the switcher, and the build.
Step 1: Create the Brand Folder
Copy the cms/brands/brand-template/ folder and rename it to the brand name. Use lowercase, hyphenated names.
cp -r cms/brands/brand-template/ cms/brands/your-brand/
The template ships dressed as a demo brand (Brand Template), so every file you copy is a working example to replace, not an empty scaffold. The folder contains:
cms/brands/your-brand/
brand.json : Admin settings (the manifest)
_defaults.md : Access control defaults
assets/
theme.css : Designer token overrides
logo.svg : Brand header logo (desktop)
logo-small.svg : Compact logo variant (mobile)
logos/ : Brand book logo set (light/dark variants)
welcome.md : Welcome page (optional)
brand-book.md : Brand book intro copy (optional)
On build, the generator copies assets/ to the output, generates the brand's pages, and builds the brand book from the logos/ folder. Nothing is hand-copied.
Set up access defaults
Update cms/brands/your-brand/_defaults.md:
---
access: "brand:your-brand"
---
This ensures all pages generated for this brand are scoped to their access level.
Step 2: Fill in the Manifest
Edit cms/brands/your-brand/brand.json:
{
"name": "Your Brand",
"description": "One line shown on the brand's home page.",
"googleFontsUrl": "https://fonts.googleapis.com/css2?family=YourFont:wght@400..700&display=swap",
"footerText": "© 2026 Your Brand"
}
| Key | Required | Description |
|---|---|---|
name |
Yes | Display name: the switcher label, page titles, and the brand home hero |
description |
No | Hero copy on the brand's index page |
googleFontsUrl |
No | Google Fonts URL, injected on brand pages and at runtime. Use null for self-hosted fonts |
typekitId |
No | Adobe Typekit kit ID (e.g. wgr3lwl). Emits the kit stylesheet link into the brand's page heads |
fontPreload |
No | Array of site-relative font file paths to preload (prevents FOUC on the primary face) |
footerText |
No | Brand footer. Falls back to the site footer when omitted |
faviconSvg / faviconIco |
No | Absolute site paths to a brand favicon. Fall back to the site set |
ogImage |
No | Absolute site path to the brand's share image. Falls back to the site default |
Font sources are per brand: a page head only carries the Typekit kit, preloads, and Google Fonts its own manifest declares. Self-hosted fonts pair a fontPreload entry with @font-face declarations in the brand's theme.css (see Step 4); the runtime preview switcher loads Google Fonts only, so Typekit-served faces show on the brand's own pages, not in a preview from another space.
The folder name is the brand key. It must match the brandFolder value in Netlify Identity.
Note: Tool access is NOT configured here. It is controlled via actionAccess in cms/*.md frontmatter. See the Access Control docs for details.
Step 3: Add Brand Logos
Logo files live in the brand source folder with fixed names:
cms/brands/your-brand/assets/logo.svg : Full header logo (desktop)
cms/brands/your-brand/assets/logo-small.svg : Compact variant (mobile, future use)
cms/brands/your-brand/assets/logos/ : Brand book set, logo_brand-<type>-<light|dark>.svg
The generator copies these to your-brand/assets/ on build and builds the brand book page from the logos/ set.
Logo requirements
- Format: SVG
- Sizing: The container is 50px tall (desktop) / 45px tall (mobile). Width scales automatically. A wide aspect ratio (roughly 2:1) works best.
- Colour: Use fixed colours, or
currentColorif you want the logo to adapt to the theme's text colour and dark mode. - Cleanup: Run logos through the SVG Cleaner before adding them:
node assets/js/svg-clean.js --strip-comments --size -o cms/brands/your-brand/assets/logo.svg <<'SVGEOF'
<svg>...paste logo SVG here...</svg>
SVGEOF
How it works
When a brand logs in, theme-loader.js fetches the logo from your-brand/assets/logo.svg and injects it into the header. When an admin previews a theme, the logo swaps too. When the theme is unloaded, the By Default logo is restored.
Step 4: Customise the Theme
Open cms/brands/your-brand/assets/theme.css and update the token values. The copied file is the Brand Template demo theme, a complete working example of the override surface.
What to override
| Category | Tokens | Purpose |
|---|---|---|
| Typography | … , plus the roles , , , | Brand fonts. Add @font-face in the theme file if self-hosting |
| Neutral scale | through | The grey ramp behind surfaces and text |
| Text | , , , , | Core text colours |
| Backgrounds | , | Surface colours |
| Borders | , , | Border and input outline colours |
| Accent | , , , --selection-* |
Accent colour across UI |
Buttons are not a theme surface: they derive from --text-primary and --text-inverted at component scope and re-skin through those tokens.
Tokens you usually don't need to override
--text-faded,--background-faded,--border-faded: alpha-based, work across any theme--status-*-bg: background tints derived from the accent primitives- Spacing, radius, type scale, breakpoints: engine rhythm, not brand expression
Dark mode
Theme the [data-theme="dark"] block, then mirror it exactly inside the @media (prefers-color-scheme: dark) fallback for users without JavaScript. Drift between the two blocks is a known failure mode; keep them identical.
How it works
Brand pages link the theme statically after the design system CSS; at runtime theme-loader.js injects the same file when a brand user logs in or an admin previews the brand. Later token values win the cascade, so the theme overrides the engine's neutral defaults and the whole site follows.
Step 5: Create the Netlify Identity User
In the Netlify Identity dashboard, create a new user and add their brand name as a role tag (e.g. your-brand). The system automatically recognises non-hierarchy role tags as brand folder names.
You can also add brand as a second tag for clarity, but it's not required. A single tag like your-brand is sufficient.
| Tag | Required? | Purpose |
|---|---|---|
your-brand |
Yes | Maps to the brand folder name, manifest key, and logo path |
brand |
Optional | Explicitly sets the access level (auto-inferred if omitted) |
The brand name tag is the single identifier that ties everything together:
- Manifest:
cms/brands/your-brand/brand.json - Theme CSS:
your-brand/assets/theme.css - Logo:
your-brand/assets/logo.svg - Home link:
your-brand/index.html - Page access: restricted to
your-brand/folder + shared tool pages
Step 6: Test in Dev Mode
On localhost, the dev toolbar lets you switch roles without logging in.
Test as brand
- Open
assets/js/auth-config.js - Set
devMode.brandFolderto your brand name:devMode: { defaultRole: 'brand', email: 'dev@bydefault.studio', brandFolder: 'your-brand' } - Open any page on localhost
- Switch to the brand role in the dev toolbar
- Verify: logo swaps, home link updates, theme loads, sidebar filters
Test as admin with theme preview
- Set
devMode.defaultRoleback to'admin' - Open any page on localhost
- Click the user icon in the header, open the dropdown
- Under Theme Preview, select your brand's theme
- Verify: theme loads, logo swaps, persists across page navigation
- Clear the preview to return to By Default
Creating from Template: Full Walkthrough
Here is the complete workflow for adding a new brand called "acme":
# 1. Copy the brand source template
cp -r cms/brands/brand-template/ cms/brands/acme/
# 2. Clean and add logos
node assets/js/svg-clean.js --strip-comments --size \
-o cms/brands/acme/assets/logo.svg <<'SVGEOF'
<svg>...paste full logo...</svg>
SVGEOF
node assets/js/svg-clean.js --strip-comments --size \
-o cms/brands/acme/assets/logo-small.svg <<'SVGEOF'
<svg>...paste small logo...</svg>
SVGEOF
Then manually:
- Edit
cms/brands/acme/brand.json: name "Acme", description, fonts URL, footer - Edit
cms/brands/acme/assets/theme.css: replace the demo values with Acme's fonts and colours - Replace the demo SVGs in
cms/brands/acme/assets/logos/with Acme's light/dark set - Update
cms/brands/acme/_defaults.md:--- access: "brand:acme" --- - Edit
cms/brands/acme/welcome.mdandbrand-book.mdif needed - Grant tool access, edit the tool markdown files to include
acme:cms/display-ad-preview-docs.md: changeactionAccess: "brand:brand-template"toactionAccess: "brand:brand-template,acme"- Update the matching
data-accessontools/display-ad-preview.html
- Run
cd cms/generator && npm run docgen - In Netlify Identity, create a user with:
{ "roles": ["brand"], "brandFolder": "acme" } - Test in dev mode
Architecture Reference
| File | Purpose |
|---|---|
cms/brands/<brand>/brand.json |
The manifest: admin settings. With assets/theme.css alongside it, the brand is live |
cms/brands/<brand>/assets/theme.css |
Designer token overrides |
assets/js/theme-config.js |
GENERATED runtime registry, built from the manifests on every docgen. Never hand-edit |
assets/js/theme-loader.js |
Loads/unloads theme CSS, fonts, logo, and home link dynamically |
assets/js/auth.js |
Calls initThemeForUser() after auth resolves |
assets/js/auth-config.js |
Role hierarchy, dev mode settings (including brandFolder for testing) |
scripts/theme-regression/check.py |
Instance-0 token regression check: run after editing the engine's tokens or By Default's theme |
Instance 0 is the exception that proves the model: cms/brands/bydefault/ holds By Default's manifest and theme, the root site loads that theme as a static link on every page, and the brand does not appear in the switcher, because clearing a preview is the return to By Default.
Data flow
Build
→ generator reads every cms/brands/*/brand.json
→ brands with assets/theme.css enter the runtime registry
→ theme-config.js is emitted in one write
→ brand pages are generated with the brand's chrome (title, footer, favicon, OG)
Login
→ auth.js resolves role + brandFolder from app_metadata
→ auth.js calls initThemeForUser(user)
→ theme-loader.js reads brandFolder
→ Sets home link to brandFolder/index.html
→ Swaps logo to brandFolder/assets/logo.svg
→ Loads brandFolder/assets/theme.css (if in the registry)
→ Injects the Google Fonts link (from the manifest, via the registry)
→ Auth filters sidebar navigation by access level
→ Page renders with full brand branding