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:
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:
- 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. decorateTemplateAndTheme- reads thetemplateandthemepage metadata and adds matching classes on<body>so CSS can target them.decorateMain/buildAutoBlocks- they live inscripts.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.
| Function | Defined in | What it does | Customise to... |
|---|---|---|---|
decorateButtons(main) | aem.js | Promotes links that sit alone in a paragraph to buttons | Change button class names, suppress promotion for specific links |
decorateIcons(main, prefix) | aem.js | Turns :icon-name: into icon spans that load SVGs from /icons/ | Swap the icon location or prefix |
decorateSections(main) | aem.js | Adds section wrappers and applies section-metadata classes | Support new section-metadata keys |
decorateBlocks(main) | aem.js | Marks every block (block class, data-block-name) so it can be loaded | Adjust block classes before loading |
buildAutoBlocks(main) | scripts.js | Synthesises blocks from markup (e.g. hero from H1+picture) | Add your own auto-blocks |
decorateMain(main) | scripts.js | Calls everything above in order | Insert 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:
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:
// 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.)
<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:
: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:
@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:
@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:
{
"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/configand.helix/headersspreadsheets 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:
- Origin - use
main--{repo}--{org}.aem.liveas the origin, with that hostname as the originHostheader. - Forwarded host - send
X-Forwarded-Host: www.example.com(your public domain) so EDS renders canonical URLs, sitemaps, and redirects for the right host. - Push invalidation - configure the production CDN in the site configuration
(
cdn.prod:host, CDNtype, and the CDN's API credentials) and sendX-Push-Invalidation: enabledfrom the CDN to the origin. EDS then purges your CDN by URL and cache key whenever content or code is published. - 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:
- Add your domain and the Edge Delivery site mapping in Cloud Manager
- Create a CNAME record pointing to
cdn.adobeaemcloud.com - 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
- Blocks - the decoration pipeline blocks plug into
- Universal Editor - block models JSON for UE
- Performance - the loading-phase contract
scripts.jshonours - Admin API - programmatically updating site config