EDS Project Structure and Content Flow
How an EDS project is organized and how authored content becomes an enhanced page. Covers the root files, the content-to-browser flow, block discovery, scripts.js loading phases, CSS loading, fonts, icons, inspecting the delivered DOM, and first-failure debugging.
Content Objective
This chapter covers:
- The purpose of every file and folder at the root of an EDS project
- How authored content becomes an enhanced page — the flow from author to browser
- Why the content already exists in the DOM before block JavaScript runs
- How to inspect the delivered DOM before writing selectors
- How block discovery maps a class name to a block folder
- The three loading phases in
scripts.jsand why they exist - The difference between
styles.css,lazy-styles.css, andfonts.css - How icons are handled and the two loading strategies
- Why the
delayed.jsfile exists and what to put in it - The naming conventions the framework enforces and what breaks when you violate them
- How to safely add third-party scripts without harming Core Web Vitals
Most developers open the EDS boilerplate, understand the blocks/ folder, and treat everything else as "framework files you don't touch."
That mental model is close to correct — but not precise enough. There are specific parts of the framework you should extend, and specific patterns for doing so safely.
Touching the wrong file in the wrong way can silently destroy your Core Web Vitals score across your entire site.
This chapter gives you the map.
The Root Structure
project-root/
├── blocks/ ← All your blocks live here
├── fonts/ ← Webfont files (.woff2, .woff)
├── icons/ ← SVG icons
├── models/ ← Shared UE field model fragments
├── scripts/ ← Core framework JS
├── styles/ ← Global CSS
├── tools/ ← Developer tools
├── 404.html ← Custom 404 page
├── component-definition.json ← UE block registry
├── component-filters.json ← UE block placement rules
├── component-models.json ← UE field definitions
├── fstab.yaml ← Content source mapping
├── head.html ← Injected into every page <head>
├── helix-query.yaml ← Query API configuration
├── helix-sitemap.yaml ← Sitemap generation rules
├── package.json ← Dev dependencies (aem CLI, linters)
└── xwalk.json ← Universal Editor app configuration
How Content Becomes an Enhanced Page
Before walking each folder, it helps to see what the folders are actually for. An EDS repository makes more sense once you stop reading it as a pile of files and start reading it as a delivery pipeline.
The flow from authored content to a finished page runs in stages:
- Authoring — the author edits content (Universal Editor in this series, or Document Authoring).
- Content delivery — EDS delivers that content as an HTML document to Preview or Live.
- Delivered HTML — the browser receives meaningful, structured HTML. The content already exists in the DOM at this point.
- Block discovery — the loader in
scripts.jsscans the document and identifies the blocks present by their class names. - Block loading — each discovered block loads its own JS and CSS from
blocks/{name}/. - Decoration —
decorate(block)runs and enhances the block it receives. - Final DOM — the page is styled and interactive.

Each folder owns one part of that pipeline. blocks/ owns step 5 and 6, scripts/ owns steps 4 and the loading orchestration, styles/ owns the global presentation, and the component JSON files own the authoring contract that shapes step 1.
The Content Exists Before Block JavaScript Runs
This is one of the most useful EDS concepts to internalize.
When the browser starts processing the page, the content structure already exists. JavaScript does not build the page from an empty application root the way a typical SPA does.
A delivered Hero, for example, may already contain:
<div class="hero block">
<div>
<div>
<picture>
...
</picture>
</div>
</div>
<div>
<div>
<h1>Explore our products</h1>
<p>Find the product that fits your needs.</p>
</div>
</div>
</div>
The exact structure depends on the authored content and the delivery pipeline. The important part is that the content is already in the DOM. Your block JavaScript receives that structure and enhances it — it does not create it.
Inspect the Delivered DOM Before Writing JavaScript
This changed how I approach EDS development. In a traditional component, I might start from the desired markup and build the template around it. In EDS, I first want to see the HTML the block actually receives.
Open DevTools, inspect the block, and ask:
- How many rows are present, and how many cells per row?
- Is the heading already an
h1orh2? - Is the image already a
<picture>? - Is the CTA already an
<a>? - Which classes are already present?
- Has the block already been decorated?
Only then do I decide what decorate(block) needs to do. This keeps you from writing JavaScript against an imagined DOM.
Block Discovery Is Driven by the Folder Name
Once the page structure is available, the loader identifies the blocks that need to load. If the page contains:
<div class="hero block">
the loader knows this is a block named hero and resolves it to:
blocks/hero/
If those names stop matching, the block will not load. Naming is not just organization here — it participates in block discovery. That is the same reason the folder naming rules in the next section matter.
Debug by Finding the First Stage That Breaks
Because the flow is a sequence, debugging becomes systematic. When a block is wrong, find the first stage where reality stops matching what you expected, and start there:
- Delivered HTML — does the DOM contain
<div class="hero block">? If not, the problem is on the content side, not inhero.js. - Block discovery — was
blocks/hero/hero.jsrequested in the Network panel? If not, check the block name. - Block loading — did
hero.jsandhero.cssload without a 404? - Decoration — did
decorate(block)run? A temporaryconsole.log('hero block', block)confirms the loader reached it. - Final DOM — did the block produce the expected result?

That sequence is much faster than rewriting a component because "Hero isn't working." We return to this first-failure method in the debugging chapter.
The blocks/ Folder
The blocks/ folder contains one subfolder per block. Each subfolder name is the block's identifier.
The folder name is what the EDS loader uses to find your block's JS and CSS. It must:
- Use kebab-case only
- Be all lowercase
- Match the
data-block-nameattribute in the page HTML exactly
blocks/
├── hero/ ← Loaded for <div class="hero block">
├── cards/ ← Loaded for <div class="cards block">
├── adc-button/ ← Loaded for <div class="adc-button block">
└── ...
The block folder is not registered anywhere in the framework. The loader discovers it dynamically based on the class names it finds in the page HTML. This means:
- A block folder with no pages using it has zero performance impact
- A block folder with typos in its file names will fail silently when a page uses it
- Deleting a block folder while pages still reference it causes a 404 for that block's JS, and the block will not decorate
The scripts/ Folder
scripts/
├── aem.js ← Core EDS library (DO NOT MODIFY)
├── scripts.js ← Page orchestration (extend carefully)
├── delayed.js ← Third-party and non-critical scripts
├── dompurify.min.js ← HTML sanitization utility
├── editor-support.js ← Universal Editor live preview support
└── editor-support-rte.js ← Universal Editor rich text support
aem.js — Do Not Modify
aem.js is the core EDS library. It provides:
- The
buildBlock(),decorateBlock(),loadBlock()utilities - Responsive image helpers
- The
getMetadata()function for reading page metadata - Icon loading utilities
- External URL detection
Never modify aem.js. It is maintained by Adobe and may be updated. Modifications will be lost on any update.
If you need to extend a utility from aem.js, import the original function and wrap it in scripts.js or in your block's JS.
scripts.js — The Orchestrator
scripts.js is the main page lifecycle manager. It runs in three distinct phases.
Phase 1: Eager Loading (Runs Immediately)
async function loadEager(doc) {
// 1. Decorate the document structure
// - Add lang attribute
// - Decorate template/theme metadata
// - Decorate main sections
// 2. Load the header and footer
// - These are loaded early because they affect layout (CLS prevention)
// 3. Wait for LCP block decoration
// - The first block in the first section is the LCP candidate
// - It is loaded and decorated synchronously before anything else
}
The eager phase is critical for Core Web Vitals. Everything here runs on the critical path. Do not add slow operations to loadEager().
The LCP (Largest Contentful Paint) block receives special treatment: it is loaded and rendered before any other block, before lazy styles, before fonts. This is how EDS achieves its performance guarantees.
Phase 2: Lazy Loading (Runs After LCP)
async function loadLazy(doc) {
// 1. Load all remaining blocks (in parallel)
// 2. Load lazy-styles.css
// 3. Load fonts.css
// 4. Load the header block's CSS
}
Blocks that are not the LCP block are loaded here, in parallel. This means thirty blocks on a page load simultaneously, not sequentially.
CSS that is not critical for above-the-fold rendering goes in lazy-styles.css and is loaded here.
Phase 3: Delayed Loading (Runs After Lazy)
async function loadDelayed() {
// Runs after a 3-second delay (or earlier if the page becomes idle)
// Import and run delayed.js
}
The delayed phase is intentionally slow. It only runs after the main page content is loaded and rendered. This is where third-party scripts belong.
delayed.js — Everything Non-Critical
delayed.js is the correct location for:
- Analytics scripts (Adobe Analytics, GA4)
- Chat widgets
- Social proof widgets
- Cookie consent managers
- A/B testing scripts
- Any third-party script that is not required for initial page render
// scripts/delayed.js
// Analytics — loads after page is ready
import('../blocks/analytics/analytics.js');
// Chat widget
const chatScript = document.createElement('script');
chatScript.src = 'https://cdn.chatwidget.com/loader.js';
chatScript.async = true;
document.head.append(chatScript);
If you add a third-party script directly to head.html, it runs in the eager phase and blocks LCP. This is the most common way EDS performance guarantees are broken by developers.
The rule: head.html is for <link rel="preconnect"> and metadata only. All actual scripts go in delayed.js.
The styles/ Folder
styles/
├── styles.css ← Global critical styles (runs in eager phase)
├── lazy-styles.css ← Global non-critical styles (runs in lazy phase)
└── fonts.css ← Font-face declarations (runs in lazy phase)
styles.css — Critical Global Styles
styles.css is loaded in the eager phase. It contains:
- CSS custom property (token) definitions
- Base typography reset
- Body and layout foundation styles
- Header and footer placeholder sizes (to prevent CLS)
- Above-the-fold styling
Keep this file lean. Everything here runs on the critical path. It should contain only what is needed to prevent layout shift and style the above-the-fold content.
The tokens defined here are available everywhere:
/* styles/styles.css */
:root {
/* Colors */
--color-charcoal: #222731;
--color-yellow: #ffd100;
--color-athens-gray: #f7f7f9;
/* Typography */
--font-size-base: 1rem;
--line-height-base: 1.5;
--font-family-sans: 'Myriad Pro', 'Open Sans', sans-serif;
/* Layout */
--section-max-width: 1440px;
--section-padding: 0 1.5rem;
}
Blocks can override tokens for their own scope:
/* blocks/hero/hero.css */
.hero {
--hero-section-height: 640px;
/* Override global token for this block */
--color-charcoal: #1a1f26;
}
lazy-styles.css — Non-Critical Global Styles
lazy-styles.css is for global styles that do not affect above-the-fold rendering:
- Print styles
- Scrollbar customization
- Animation base classes
- Global utility classes used only in below-the-fold content
- Third-party CSS overrides
Most EDS projects have very little in this file. If you find yourself putting a lot of styles here, reconsider whether they belong in a specific block's CSS instead.
fonts.css — Webfont Loading
fonts.css contains the @font-face declarations for custom fonts. It is loaded in the lazy phase to avoid render-blocking.
/* styles/fonts.css */
@font-face {
font-family: 'Myriad Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/MyriadPro-Regular.woff2') format('woff2'),
url('/fonts/MyriadPro-Regular.woff') format('woff');
}
Always use font-display: swap. Without it, text is invisible until the font loads (FOIT — Flash of Invisible Text), which hurts both CLS and LCP.
The font files themselves live in the fonts/ folder at the root:
fonts/
├── MyriadPro-Regular.woff2
├── MyriadPro-Regular.woff
├── MyriadPro-Bold.woff2
└── MyriadPro-Bold.woff
Always serve .woff2 first. It is smaller (typically 30% smaller than .woff) and is supported by all modern browsers.
For system fonts or Google Fonts, add a <link rel="preconnect"> in head.html and reference the font URL in fonts.css. Do not add a blocking <link rel="stylesheet"> for Google Fonts in head.html.
The icons/ Folder
icons/
├── icon-arrow.svg
├── icon-check.svg
└── icon-logo.svg
Icons are SVG files. EDS provides a standard way to use them in authored content using the :icon-name: syntax in Document Authoring, which renders as <span class="icon icon-name"><img src="/icons/icon-name.svg" /></span> in the HTML.
For Universal Editor blocks, you reference icons in your JS:
import { getIcon } from '../../scripts/aem.js';
// Returns an img element pointing to /icons/arrow.svg
const arrowIcon = getIcon('arrow');
Two Icon Loading Strategies
Strategy 1: External Reference (default)
EDS loads icons as <img> elements by default. This means they are cached by the browser but cannot be styled with CSS color or fill.
Use this for: multi-color icons, complex illustrations, logos.
Strategy 2: Inline SVG
For icons that need to respond to CSS (for example, changing color on hover), you need to inline the SVG into the DOM. The aem.js library provides a utility for this:
import { decorateIcons } from '../../scripts/aem.js';
// After building your DOM, call this to inline SVG icons
// It replaces <img class="icon-*"> with the actual SVG content
decorateIcons(block);
This fetches the SVG and replaces the <img> element with the <svg> element, making it styleable.
Use inline SVG for: single-color icons where hover/focus color change is needed, animated SVG icons, icons in dark mode scenarios.
The models/ Folder
Covered in Chapter 2, but for reference:
models/
├── _button.json ← Reusable button field definition
├── _image.json ← Reusable image field definition
├── _text.json ← Reusable rich text field
├── _page.json ← Page-level fields (used by the page model)
└── _section.json ← Section-level styling fields
These files use JSON $ref or definitions patterns to share field definitions across multiple blocks. When multiple blocks have the same field type (e.g., a "Button" with label, link, style, and action fields), define it once in models/ and reference it.
The tools/ Folder
tools/
└── sidekick/
└── config.json ← Sidekick extension configuration
config.json configures the AEM Sidekick extension's behavior for your project — which environments appear in the Sidekick, which plugins are enabled, and custom actions.
For most projects, the default configuration works. You modify this only when you need custom Sidekick plugins or to add staging/QA environment URLs to the toolbar.
Key Configuration Files at Root
404.html
A custom 404 page. It is a full HTML file that is served when a requested page is not found.
For the best experience, the 404 page should match your site's header and footer. The simplest approach is to reference your header and footer blocks in the 404 HTML.
head.html
Injected into <head> on every page. Only use for:
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- Verification meta tags -->
<meta name="google-site-verification" content="...">
<!-- Canonical URL if needed globally -->
Never put scripts here unless they are truly critical and render-blocking is intentional (extremely rare). Never put analytics or tracking here.
helix-query.yaml
Configures the content query API. When you need a block that lists pages (blog index, news archive, search results), it uses the query API to fetch page metadata.
The query API provides a JSON feed of all pages in your site with their metadata. You configure which metadata fields are indexed here.
version: 1
indices:
blog:
include:
- /blog/**
exclude:
- /blog/index
target: /blog-query-index.json
properties:
title:
select: head > meta[property="og:title"]
value: attribute(el, 'content')
description:
select: head > meta[name="description"]
value: attribute(el, 'content')
date:
select: head > meta[name="publication-date"]
value: attribute(el, 'content')
helix-sitemap.yaml
Configures sitemap.xml generation. For SEO-critical sites, configure this to include the correct pages and exclude drafts or author-only pages.
xwalk.json
The Universal Editor application configuration. Tells UE which configuration files (component-definition, component-models, component-filters) to load for this project.
{
"version": 1,
"definitions": [
"component-definition.json",
"component-models.json",
"component-filters.json"
]
}
Normally, you never modify this file. The three JSON files it references are what you actively maintain.
The Three Most Common Structural Mistakes
Mistake 1: Adding analytics to head.html
<!-- DO NOT DO THIS -->
<script async src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
This makes GTM load in the eager phase, potentially blocking LCP. Move it to delayed.js.
Mistake 2: Putting all global styles in styles.css
/* DO NOT put non-critical styles here */
/* This makes your critical CSS load larger, slowing initial render */
.footer-social-links { ... }
.cookie-banner { ... }
.newsletter-form { ... }
Split: critical above-the-fold styles in styles.css, everything else in lazy-styles.css or in block-specific CSS files.
Mistake 3: Modifying aem.js
// DO NOT MODIFY aem.js
// If you need to change utility behavior, import and wrap it in scripts.js
import { getMetadata } from './aem.js';
export function getTypedMetadata(name) {
const value = getMetadata(name);
// Custom handling
return value;
}
Changes to aem.js will be overwritten if you update the framework version.
Adding a New Section Type
EDS pages are divided into <main> → sections → blocks. Sections are <div> elements inside <main>.
By default, all sections are plain divs. Section-level styling is applied through metadata at the bottom of each section in the authored document:
| Section Metadata |
|---------------------|
| Style | dark-bg |
This adds data-section-style="dark-bg" to the section <div> in the HTML.
In styles.css or lazy-styles.css, you apply the style:
main > div[data-section-style='dark-bg'] {
background-color: var(--color-charcoal);
color: white;
}
This is the EDS way to do section-level backgrounds without a dedicated block.
Key Takeaways
- A repository is a delivery pipeline — authored content becomes delivered HTML, blocks are discovered by class name, and
decorate()enhances what already exists - Content exists in the DOM before block JS runs — inspect the delivered DOM before writing selectors
- Block discovery maps a class name to a folder —
class="hero block"loadsblocks/hero/ - Debug by first failure — find the first stage (HTML, discovery, loading, decoration, final DOM) where reality stops matching, and start there
aem.jsis read-only — never modify it; wrap utilities inscripts.jsif needed- scripts.js runs in three phases: eager (critical path), lazy (after LCP), delayed (non-critical, 3s delay)
- Third-party scripts belong in
delayed.js— nothead.html, not inline styles.cssis critical-path CSS — keep it lean, above-the-fold onlyfonts.cssandlazy-styles.cssload in the lazy phase — safe for non-critical styles- Always use
font-display: swapto prevent FOIT - Block CSS is scoped per-block — loaded only when that block is on the page
- Section-level styling uses
Section Metadatain the document — no extra block needed - Icons can be external (img) or inline (SVG) — use inline only when CSS color control is needed
head.htmlis for preconnect and meta tags only — scripts there block LCP
Next Steps
Now you know where everything lives and how a page comes together. In the next chapter, we cover the most important concept in EDS development: Blocks.
We will cover:
- The anatomy of a block (JS, CSS, JSON model)
- How
scripts.jsdiscovers and loads blocks - The
decorate(block)function and how to use it correctly - Reading key-value content from the block DOM
- Building your first block from scratch
- The difference between
key-value: trueblocks and regular multi-cell blocks - Common block mistakes that cause silent failures
Enjoyed this chapter?
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.