Skip to main content

Customizing

Out of the box, EDS gives you a working site. The interesting work is in shaping it: adding a theme, swapping decoration logic, configuring response headers, wiring analytics, and pulling in shared plugins. Almost everything is project-local code in GitHub - there are no servers to configure.

scripts/scripts.js - your global init​

scripts.js orchestrates the page. The boilerplate ships a working version that calls the three lifecycle phases from aem.js:

scripts/scripts.js (abridged)
import {
loadHeader,
loadFooter,
decorateButtons,
decorateIcons,
decorateSections,
decorateBlocks,
decorateTemplateAndTheme,
waitForFirstImage,
loadSection,
loadSections,
loadCSS,
} from './aem.js';

function buildAutoBlocks(main) {
// synthesise blocks from default content, e.g. a hero from the first h1 + picture
}

export function decorateMain(main) {
decorateButtons(main);
decorateIcons(main);
buildAutoBlocks(main);
decorateSections(main);
decorateBlocks(main);
}

async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();
const main = doc.querySelector('main');
if (main) {
decorateMain(main);
document.body.classList.add('appear');
await loadSection(main.querySelector('.section'), waitForFirstImage);
}
}

async function loadLazy(doc) {
const main = doc.querySelector('main');
await loadSections(main);
loadHeader(doc.querySelector('header'));
loadFooter(doc.querySelector('footer'));
loadCSS(`${window.hlx.codeBasePath}/styles/lazy-styles.css`);
}

function loadDelayed() {
window.setTimeout(() => import('./delayed.js'), 3000);
}

async function loadPage() {
await loadEager(document);
await loadLazy(document);
loadDelayed();
}

loadPage();

The real boilerplate also loads fonts (loadFonts()) and restores the scroll position for #hash links; those lines are omitted here. Three things you typically customise:

  1. The first section - loadEager() decorates only the first section and waits for its first image. Keep the LCP element (usually the hero) in that section.
  2. decorateTemplateAndTheme - reads the template and theme page metadata and adds matching classes on <body> so CSS can target them.
  3. decorateMain / buildAutoBlocks - they live in scripts.js, so project-specific decoration passes and auto-blocks go directly in there.

Decoration overrides​

aem.js exports the building blocks that decorateMain in scripts.js calls. Because decorateMain is project code, you customise the pipeline by editing it, not by forking aem.js.

FunctionDefined inWhat it doesCustomise to...
decorateButtons(main)aem.jsPromotes links that sit alone in a paragraph to buttonsChange button class names, suppress promotion for specific links
decorateIcons(main, prefix)aem.jsTurns :icon-name: into icon spans that load SVGs from /icons/Swap the icon location or prefix
decorateSections(main)aem.jsAdds section wrappers and applies section-metadata classesSupport new section-metadata keys
decorateBlocks(main)aem.jsMarks every block (block class, data-block-name) so it can be loadedAdjust block classes before loading
buildAutoBlocks(main)scripts.jsSynthesises blocks from markup (e.g. hero from H1+picture)Add your own auto-blocks
decorateMain(main)scripts.jsCalls everything above in orderInsert custom passes before / after

Strategy: don't fork aem.js - keep it identical to upstream so you can update it, and add project-specific passes in scripts.js:

scripts/scripts.js (excerpt)
export function decorateMain(main) {
decorateBlogTeaser(main); // project-specific
decorateButtons(main);
decorateIcons(main);
buildAutoBlocks(main);
decorateSections(main);
decorateBlocks(main);
decorateAnchorLinks(main); // project-specific
}

This keeps you on the upgrade path when aem.js ships changes.

scripts/delayed.js - the analytics dumping ground​

Anything that doesn't need to render or shift layout belongs here:

scripts/delayed.js
// Adobe Launch
const launchScript = document.createElement('script');
launchScript.src = 'https://assets.adobedtm.com/.../launch.min.js';
launchScript.async = true;
document.head.append(launchScript);

// Plausible
const plausible = document.createElement('script');
plausible.src = 'https://plausible.io/js/script.js';
plausible.dataset.domain = 'example.com';
plausible.defer = true;
document.head.append(plausible);

// Cookie consent banner
import('https://cdn.cookieconsent.example.com/banner.js');

The 3-second delay (configured in scripts.js) means none of this hits Lighthouse.

head.html - early <head> content​

head.html is injected into the <head> of every page, next to the metadata that EDS generates from the document. Use it for:

  • The script and stylesheet tags that bootstrap your code
  • Favicon and PWA manifest links
  • Verification meta tags (Google Site Verification, etc.)
head.html
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="/scripts/aem.js" type="module"></script>
<script src="/scripts/scripts.js" type="module"></script>
<link rel="stylesheet" href="/styles/styles.css">

<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">

<meta property="og:image" content="https://www.example.com/og-default.jpg">

The <script> and <link rel="stylesheet"> lines are the bridge to your code - they must be present. Keep everything else minimal: every extra request in <head> competes with the LCP.

styles/styles.css - the theme​

styles.css is the global stylesheet and the home for design tokens:

styles/styles.css
:root {
/* colours */
--text-color: #1a1a1a;
--background-color: #ffffff;
--link-color: #0061fe;
--link-hover-color: #003ea1;
--highlight-background-color: #f5f5f5;

/* typography */
--body-font-family: 'Inter', sans-serif;
--heading-font-family: 'Inter Display', sans-serif;
--body-font-size-m: 1.125rem;
--body-font-size-s: 1rem;
--heading-font-size-xxl: clamp(2.5rem, 4vw, 4rem);

/* spacing */
--spacing-s: 0.5rem;
--spacing-m: 1rem;
--spacing-l: 2rem;
}

[data-theme='dark'] {
--text-color: #f5f5f5;
--background-color: #111111;
--link-color: #5fa8ff;
}

Block CSS should consume these variables, not hard-code values. That keeps theme changes (and the data-theme toggle) one-line.

styles/lazy-styles.css holds below-the-fold styles - loaded after LCP so it doesn't delay first paint.

Fonts​

Declare fonts with font-display: swap in styles/fonts.css:

styles/fonts.css
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/inter.woff2') format('woff2');
}

Do not preload fonts in head.html. Web fonts compete with the LCP image for bandwidth, so the boilerplate loads fonts.css via loadFonts() in the lazy phase (or eagerly only on repeat visits, once a session flag says the fonts are cached). To avoid a layout shift when the web font swaps in, define a size-adjusted local fallback font:

styles/styles.css
@font-face {
font-family: 'inter-fallback';
size-adjust: 107%;
src: local('Arial');
}

:root {
--body-font-family: 'Inter', 'inter-fallback', sans-serif;
}

Self-host fonts wherever possible - third-party font CDNs add another connection.

paths.json - AEM path mappings​

For AEM-authored (Universal Editor) sites, paths.json maps AEM repository paths to public URLs and lists which AEM paths may be published:

paths.json
{
"mappings": [
"/content/mysite/:/",
"/content/mysite/configuration:/.helix/config.json",
"/content/mysite/metadata:/metadata.json"
],
"includes": [
"/content/mysite/"
]
}

Each mapping is <AEM path>:<public path>, so authors work under /content/mysite/... while visitors see clean URLs.

For HTTP redirects, author a redirects.json (or redirects.xlsx) at the content source root.

Site configuration - response headers and CDN​

Site-level behaviour (custom response headers, CDN settings, access control, Sidekick configuration) is not stored in the code repository. It lives in the site configuration:

  • Configuration Service - JSON managed through the Admin API at https://admin.hlx.page/config/{org}/sites/{site}.json (see Admin API).
  • Document-based projects - the .helix/config and .helix/headers spreadsheets in the content source (older setups).

Custom headers are keyed by URL glob. With the Configuration Service:

curl -X POST "https://admin.hlx.page/config/{org}/sites/{site}/headers.json" \
-H "Content-Type: application/json" \
-H "X-Auth-Token: $EDS_ADMIN_TOKEN" \
--data '{
"/**": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "Content-Security-Policy", "value": "default-src '\''self'\'' https:; img-src '\''self'\'' https: data:" },
{ "key": "Permissions-Policy", "value": "geolocation=(), camera=(), microphone=()" }
]
}'

Custom headers are one of the most under-used features. Lock down CSP, HSTS, X-Frame-Options, and Permissions-Policy here - they apply at the edge before HTML reaches the browser.

Bring your own CDN​

Adobe ships a managed CDN, but you can place Akamai, Cloudflare, Fastly, or CloudFront in front of *.aem.live:

  1. Origin - use main--{repo}--{org}.aem.live as the origin, with that hostname as the origin Host header.
  2. Forwarded host - send X-Forwarded-Host: www.example.com (your public domain) so EDS renders canonical URLs, sitemaps, and redirects for the right host.
  3. Push invalidation - configure the production CDN in the site configuration (cdn.prod: host, CDN type, and the CDN's API credentials) and send X-Push-Invalidation: enabled from the CDN to the origin. EDS then purges your CDN by URL and cache key whenever content or code is published.
  4. Without push invalidation - keep HTML TTLs short (minutes), because your CDN will keep serving stale content until its TTL expires.

Custom domain via the Adobe-managed CDN: add the domain in Cloud Manager and create a CNAME to cdn.adobeaemcloud.com; TLS certificates are managed there.

/plugins - shared utilities​

The /plugins/ directory hosts shared utilities consumed by multiple blocks. Common examples:

  • /plugins/experimentation/ - the experimentation runtime when the project has adopted the experimentation plugin
  • /plugins/martech/ - shared Adobe Launch / Analytics integration
  • /plugins/rum/ - Real User Monitoring sampling
  • /plugins/sidekick/ - the Sidekick Library

Plugins are imported from blocks the same as any local module:

import { trackEvent } from '../../plugins/martech/martech.js';

export default function decorate(block) {
block.querySelectorAll('a').forEach((a) => {
a.addEventListener('click', () => trackEvent('cta-click', { href: a.href }));
});
}

Some plugins ship via npm; others are vendored (copied) into the repo. Vendoring keeps the deploy frictionless - no npm install step in production.

Custom domains and TLS​

For the simplest setup with the Adobe-managed CDN:

  1. Add your domain and the Edge Delivery site mapping in Cloud Manager
  2. Create a CNAME record pointing to cdn.adobeaemcloud.com
  3. Add or let Cloud Manager provision the TLS certificate for the domain

For BYO CDN, terminate TLS at your CDN and forward to main--{repo}--{org}.aem.live over HTTPS.

See also​