Edge Delivery Services · 27 of 29
How to build an author-driven theme system in EDS. Covers per-page theming, folder/country-level inheritance via the metadata sheet, breaking inheritance with page overrides, and an honest comparison to AEM property inheritance.
This chapter covers:
Coming from AEM, "theming" means a page property that a page inherits from its ancestors via getInherited, resolved server-side by a Sling Model. EDS has no server-side page hierarchy at render time — every page is a standalone static document. So the interesting question is not "how do I set a theme" but "how do I make a theme cascade down a folder like AEM does, when there is no live inheritance?"
This chapter answers that end to end, using a real two-theme setup (theme1 "abc" and theme2 "Ocean").
In AEM, theme inheritance is a runtime tree walk. A page asks "what is my theme?", and if it has no value, the resource resolver walks up the content tree to an ancestor that does. The inheritance is live, server-side, and automatic.
In EDS there is no tree to walk at render time. Each published page is a flat HTML document delivered from the CDN. There is no ancestor to ask. So EDS reconstructs inheritance a different way:
Inheritance in EDS is resolved at publish time and expressed as a single
<meta name="theme">tag on each page.
Everything downstream — the CSS that loads, the colors that render — keys off that one tag. The whole design problem reduces to: how does the right theme meta tag end up in each page's <head>?
There are exactly two authoring mechanisms that put it there, and understanding how they combine is the entire theme system.
The theme system is three layers stacked on top of each other:
theme1.css, theme2.css) — what a theme looks like.loadTheme() in scripts.js — reads the theme meta tag and loads the matching CSS.Layers 1 and 2 are code (written once). Layer 3 is where authors work every day.
A theme is a file of design tokens. No build, no SCSS, no compilation.
/* styles/themes/theme1.css — "abc" */
:root {
--color-charcoal: #222731;
--color-yellow: #ffd100;
--color-blue: #001489;
}
.header-global { --header-bg: var(--color-charcoal); }
.footer-global { --footer-bg-color: var(--color-charcoal); }
/* styles/themes/theme2.css — "Ocean" */
:root {
--color-charcoal: #0a1f44; /* deep navy */
--color-yellow: #00b0b9; /* teal */
--color-blue: #0057b8;
}
.header-global { --header-bg: var(--color-charcoal); }
.footer-global { --footer-bg-color: var(--color-charcoal); }
Both files define the same token names with different values. Header and footer CSS read those tokens (var(--header-bg)), so swapping the file swaps the entire look. This is the CSS custom property token hierarchy applied to whole-site theming.
async function loadTheme() {
const theme = document.querySelector('meta[name="theme"]')?.content?.trim() || 'theme1';
document.body.classList.add(`theme-${theme}`);
await loadCSS(`${window.hlx.codeBasePath}/styles/themes/${theme}.css`);
}
Three lines of logic:
theme meta tag (default theme1 if absent).theme-x body class (a hook if any CSS ever needs to key off the theme).It runs in loadEager — before first paint — so there is no flash of the wrong theme.
Notice there is no country logic, no path map, nothing hardcoded. That is deliberate. The code never decides the theme; authors do. This is the single most important design decision in the whole system: keep the code generic, move the decision to content.
This is where "single page", "few pages", and "whole country" all live. Two mechanisms feed the same meta tag.
Before any scenario works, the theme system has to exist in code. This is a one-time setup. After this, everything is authoring.
| File | Purpose | Touched when |
|---|---|---|
styles/themes/theme1.css | Default theme tokens | Adding/editing a theme |
styles/themes/theme2.css | Second theme tokens | Adding/editing a theme |
scripts/scripts.js → loadTheme() | Reads the meta, loads the CSS | Once (generic, never per-country) |
component-models.json | Adds the Theme dropdown to the UE Page panel | Enabling Scenario A/B |
models/_page.json | Source of the page model — keep in sync | Enabling Scenario A/B |
metadata sheet (in AEM content) | Folder/country theme rules | Enabling Scenario C |
Add one file per theme under styles/themes/. Each defines the same token names with different values (see Layer 1 above). Nothing else references a specific theme — the loader builds the path from the theme name.
loadTheme() into scripts.jsAdd the function (Layer 2) and call it from loadEager() before the main content decorates, so the theme CSS is in place before first paint:
async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();
await loadTheme(); // ← theme resolved before paint
// ...decorateMain, etc.
}
The UE Page panel is driven by the page-metadata model. Add a select field named exactly theme (the value must match the CSS file name and what loadTheme() reads).
component-models.json (the generated/served file the editor reads):
{
"id": "page-metadata",
"fields": [
{ "component": "text", "valueType": "string", "name": "jcr:title", "label": "Title" },
{ "component": "text", "valueType": "string", "name": "jcr:description", "label": "Description" },
{ "component": "text", "valueType": "string", "name": "keywords", "multi": true, "label": "Keywords" },
{
"component": "select",
"valueType": "string",
"name": "theme",
"label": "Theme",
"options": [
{ "name": "Theme 1 (Default)", "value": "theme1" },
{ "name": "Theme 2", "value": "theme2" }
]
}
]
}
Critical sync gotcha. In this project the
themefield was added to the generatedcomponent-models.jsonbut not to the sourcemodels/_page.json. The generated files are built from the sources (models/_*.json). If a rebuild ever runs, it will overwritecomponent-models.jsonand wipe the Theme field. To make it durable, add the samethemefield tomodels/_page.jsonas well, so source and generated stay in sync. Two files, identical field.
With these four things in place, Scenarios A and B work. Scenario C needs the metadata sheet (documented in its own section below).
Goal: theme just this page.
Files involved: none to edit — this is pure authoring, powered by the setup above (component-models.json provides the field, loadTheme() + theme2.css do the rest).
Steps:
https://experience.adobe.com/#/aem/editor/canvas/author-p153710-e1614654.adobeaemcloud.com/content/2026/36/astutejaguar70099/index.html
(Not the classic Sites → Properties dialog — that dialog does not show EDS model fields.)AEM writes <meta name="theme" content="theme2"> into that page. On the published .aem.page URL, loadTheme() reads it and loads theme2.css.
The theme renders on the published/preview page, not repainted live inside the UE canvas. Judge the result on the published URL.
Goal: theme a handful of specific pages (a campaign page, a seasonal microsite).
Files involved: none — repeat Scenario A per page.
This is fine for a few exceptions. It does not scale to hundreds of pages and it does not cascade — each page is set independently. If you find yourself setting the dropdown on more than a few pages, use Scenario C.
Goal: every page under /keenpanther20891 uses Theme 2, automatically, including pages that do not exist yet — set once.
This is the EDS equivalent of AEM inherited page properties, and it is done with the metadata sheet — a spreadsheet named metadata at the site content root. The docs confirm this works with AEM as the content source, not just Google/SharePoint.
File/asset involved: a new metadata spreadsheet in AEM (content, not code). No code changes.
One sheet, at least two columns:
| URL | theme |
|---|---|
/** | theme1 |
/keenpanther20891/** | theme2 |
Rules that make it behave like inheritance:
** matches any depth. /keenpanther20891/** covers every current and future child page. Set once, applies to all — exactly the AEM mental model./** default must be the first row; more specific folders go below and win./content/... AEM path. Verify the real .aem.page path of a folder page first (see below).metadata sheet for it to take effect..aem.page URL, e.g.
https://main--eds-poc--naveenrapelly34.aem.page/keenpanther20891/about.
The path after the domain (/keenpanther20891/...) is what the glob must match — not /content/2026/36/....metadata spreadsheet at the content root (the same level the site root / maps to). In an AEM-authored site you create it as a spreadsheet resource named metadata — one worksheet, the columns above./** first, then each specific folder./metadata.json.<meta name="theme" content="theme2"> and renders Theme 2.At publish time, Helix injects the meta tag into every matching page. The pages themselves stay untouched — you never open them. Adding a new country later is one new row and a republish. No developer, no deploy.
Published once, the metadata sheet maps path globs to theme values:
/** → theme1 (the default for the whole site)./keenpanther20891/** → theme2 (one country, all of its pages).Every page under /keenpanther20891/ — about, products, contact, and any page added later — inherits theme2 with no per-page work.
On a published country page, open DevTools and check the <head>:
<meta name="theme" content="theme2">
If it's there, loadTheme() will load theme2.css. If it's missing: the sheet wasn't published, the glob doesn't match the real path, or a page-level override is winning (see Scenario D).
Goal: the whole country is Theme 2, but one page inside it must be Theme 1.
Because the per-page dropdown (Scenario A) takes precedence over the metadata sheet (Scenario C), you simply open that one page in the Universal Editor and set Theme → Theme 1. Its own <meta> wins over the folder default. Every sibling stays Theme 2.
This is the direct equivalent of overriding an inherited property on a single AEM page — and it works the same way conceptually.
flowchart TD
A[Page renders] --> B{Page has its own<br/>Theme meta<br/>UE dropdown?}
B -->|Yes| C[Use that theme<br/>page override wins]
B -->|No| D{URL matches a row<br/>in metadata sheet?}
D -->|Yes| E[Use folder theme<br/>e.g. theme2]
D -->|No| F[Default: theme1]
Page property > metadata sheet > code default. That single ordering gives you: a site-wide default, per-country overrides, and per-page exceptions — the full AEM inheritance behavior, reassembled from static parts.
| Capability | AEM | EDS | Notes |
|---|---|---|---|
| Theme on a single page | Page property | UE Theme dropdown | Direct equivalent |
| Theme on a folder subtree | Inherited property (getInherited) | Metadata sheet /folder/** | EDS resolves at publish, not runtime |
| Override on one child page | Set property on the page | UE dropdown on the page | Direct equivalent — breaks inheritance |
| New pages auto-inherit | Yes, live | Yes, via ** glob on publish | Same outcome, different timing |
| Change theme site-wide instantly | Rollout / republish | Edit one sheet row + republish | EDS is often simpler here |
| Runtime tree walk for theme | Yes (server-side) | No — flat meta tag per page | The core architectural difference |
| No build step for theme CSS | No (ClientLib compile) | Yes — plain CSS files | EDS advantage |
| Author sees theme live while editing | Yes (in-page) | Only on published/preview render | EDS limitation |
/content/... AEM path. Always verify the real .aem.page path before writing a glob.For a multi-country site:
styles/themes/, and the generic loadTheme() in scripts.js. No country names in code, ever.metadata sheet with /** → theme1 as the site-wide baseline./uk/** → theme2, /us/** → theme1.That gives you AEM-style inheritance, per-page overrides, and instant site-wide changes — with a three-line theme loader and zero hardcoding.
<meta name="theme"> tag resolved at publish time.loadTheme() reads the meta and loads CSS — it never decides the theme./** globs. Break inheritance → dropdown override on one page.The next chapter, EDS Blocks and External API Integration, moves from presentation to data: how a block combines authored content with runtime data from an external API, and the boundaries — CORS, credentials, and what has to stay server-side — that come with it.
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.