N
Naveenr.dev
Chapter 04
18 min read2026-06-30

EDS Project Structure

Project structure for EDS. Covers the root files, scripts.js loading phases, CSS loading, fonts, icons, delayed.js, and naming conventions.

Content Objective

This chapter covers:

  • The purpose of every file and folder at the root of an EDS project
  • The three loading phases in scripts.js and why they exist
  • The difference between styles.css, lazy-styles.css, and fonts.css
  • How icons are handled and the two loading strategies
  • Why the delayed.js file 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

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-name attribute 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

  • aem.js is read-only — never modify it; wrap utilities in scripts.js if 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 — not head.html, not inline
  • styles.css is critical-path CSS — keep it lean, above-the-fold only
  • fonts.css and lazy-styles.css load in the lazy phase — safe for non-critical styles
  • Always use font-display: swap to prevent FOIT
  • Block CSS is scoped per-block — loaded only when that block is on the page
  • Section-level styling uses Section Metadata in the document — no extra block needed
  • Icons can be external (img) or inline (SVG) — use inline only when CSS color control is needed
  • head.html is for preconnect and meta tags only — scripts there block LCP

Next Steps

In the next chapter, we will cover Common Issues and Debugging.

We will go through the real errors that appear in production EDS projects:

  • Why blocks load but render incorrectly
  • The exact cause of "image not showing" in complex variant blocks
  • JSON crashes in Universal Editor and how to read the error
  • Why your CSS variant rule has no effect
  • Content not updating after author edits
  • Debugging tools: what to look for in Network, Console, and Elements tabs

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.