N
Naveenr.dev
Chapter 35
11 min read•2026-10-04

AEM Component Frontend Architecture

A developer and architect guide to organizing CSS and JavaScript around AEM components, covering markup contracts, BEM, data attributes, initialization, lifecycle, Clientlib boundaries, authoring behavior, accessibility, performance, and production troubleshooting.

AEM Component Frontend Architecture

By this point in the series, we have covered two large pieces of AEM frontend delivery.

Chapter 32 covered Clientlibs: how CSS and JavaScript are represented and delivered by AEM.

Chapter 33 covered the frontend build pipeline: how source code in ui.frontend becomes deployable frontend artifacts.

Chapter 34 covered responsive layout and image delivery.

There is still another boundary that causes problems in large AEM projects:

How should frontend code be organized around an individual AEM component?

A component can look simple in the repository:

text
/apps/myproject/components/product-card

But its browser behavior may involve:

  • HTL markup,
  • Sling Model output,
  • CSS,
  • JavaScript,
  • author-configured variations,
  • responsive behavior,
  • accessibility,
  • analytics hooks,
  • authoring behavior.

If those responsibilities are not separated carefully, a site eventually ends up with one large site.js, selectors coupled to page structure, global CSS overrides, duplicate event handlers, and components that work only in one template.

Component frontend architecture is about preventing that.

Start With the Component Boundary

Consider a product card rendered by HTL:

html
<article
    class="cmp-product-card"
    data-cmp-is="product-card">

    <h3 class="cmp-product-card__title">
        Product name
    </h3>

    <button
        class="cmp-product-card__action"
        data-cmp-hook-product-card="action">
        View details
    </button>
</article>

This markup creates several different contracts.

cmp-product-card identifies the component's styling block.

cmp-product-card__title and cmp-product-card__action identify elements within that block.

data-cmp-is="product-card" gives JavaScript an explicit initialization marker.

data-cmp-hook-product-card="action" gives JavaScript a behavior hook without requiring it to depend on a visual CSS selector.

That separation is more important than the exact naming convention.

CSS needs stable styling hooks.

JavaScript needs stable behavioral hooks.

HTL owns the semantic markup that connects them.

Learn From Core Components

AEM Core Components provide a useful reference for this boundary.

Adobe documents Core Components as themeable components whose markup follows BEM conventions. Individual Core Component implementations also expose explicit JavaScript data bindings.

For example, the Core Image Component uses a component wrapper and data-cmp-is="image" for JavaScript initialization, while component-specific data attributes provide configuration.

This gives us a useful pattern for custom components:

  • use component-scoped CSS classes for styling,
  • use explicit data attributes for JavaScript behavior/configuration,
  • avoid making JavaScript depend on accidental page structure.

The goal is not to copy every internal detail of Core Components.

The goal is to preserve a stable browser-side component contract.

BEM Gives CSS a Component Boundary

A component can use BEM-style naming:

text
cmp-product-card
cmp-product-card__image
cmp-product-card__content
cmp-product-card__title
cmp-product-card__action

A variation can be represented separately:

text
cmp-product-card--featured

This keeps selectors understandable.

Instead of:

css
.page .container .column div.card h3 {
    ...
}

we can target:

css
.cmp-product-card__title {
    ...
}

The first selector describes where the component happened to be placed.

The second describes what the element is.

That distinction becomes important when authors move the component into another container or template.

A reusable component should not stop working because its parent DOM changed.

Keep Styling Hooks and JavaScript Hooks Separate

It is possible to write:

javascript
document.querySelectorAll(".cmp-product-card__action");

and use the CSS class as the JavaScript selector.

For a small component, that may work.

But it couples two contracts.

A frontend developer may later rename the class during a styling refactor and unintentionally break behavior.

A stronger boundary is:

html
<button
    class="cmp-product-card__action"
    data-cmp-hook-product-card="action">

CSS uses:

css
.cmp-product-card__action {
    ...
}

JavaScript uses:

javascript
element.querySelector(
    '[data-cmp-hook-product-card="action"]'
);

Now the styling name can evolve without silently changing the behavioral contract.

Not every component needs a custom data-hook system, but interactive components benefit from explicit behavior selectors.

JavaScript Should Initialize the Component, Not the Page

A common early implementation looks like:

javascript
window.addEventListener("load", function () {
    const button = document.querySelector(".product-button");

    button.addEventListener("click", function () {
        // ...
    });
});

This assumes:

  • only one component exists,
  • the element exists when the page initializes,
  • the selector is globally unique,
  • the component will never be inserted later,
  • initialization happens only once.

Those assumptions are fragile in AEM.

Authors can place multiple instances of the same component on one page.

Authoring can modify the DOM.

Other frontend code may initialize at different times.

A better design initializes each component instance independently.

javascript
function ProductCard(element) {
    const action = element.querySelector(
        '[data-cmp-hook-product-card="action"]'
    );

    if (!action) {
        return;
    }

    action.addEventListener("click", () => {
        // Component behavior
    });
}

document
    .querySelectorAll('[data-cmp-is="product-card"]')
    .forEach((element) => {
        ProductCard(element);
    });

Now the code works with multiple component instances and keeps DOM queries inside the component boundary.

Initialization Must Be Idempotent

There is another problem.

What happens if initialization runs twice?

Without protection, the same element may receive two event listeners.

A simple component can mark its initialized state:

javascript
function ProductCard(element) {
    if (element.dataset.cmpInitialized === "true") {
        return;
    }

    element.dataset.cmpInitialized = "true";

    const action = element.querySelector(
        '[data-cmp-hook-product-card="action"]'
    );

    if (!action) {
        return;
    }

    action.addEventListener("click", () => {
        // Component behavior
    });
}

The exact mechanism can vary.

The requirement is more important:

Running component initialization again should not corrupt the component or duplicate its behavior.

This matters in AEM authoring, asynchronous rendering, SPA-like experiences, and any page where DOM fragments can appear after the initial document load.

Author Mode Changes the DOM Lifecycle

A component that works perfectly on Publish can behave differently inside the Page Editor.

During authoring, components can be inserted, moved, configured, and re-rendered without a normal full-page navigation.

If frontend code assumes:

DOMContentLoaded runs once, therefore my component exists forever,

it can miss newly inserted markup.

The solution is not to fill every component with authoring-specific code.

First design initialization so it can safely run against a DOM subtree more than once.

Then, if the project needs to react to AEM authoring lifecycle events, keep that integration in a small authoring-aware boundary that asks the normal component initializer to scan the changed content.

That keeps normal runtime behavior separate from editor integration.

Do Not Put Everything in site.js

Chapter 33 showed that component source can be organized independently even when webpack ultimately creates a site bundle.

For example:

text
ui.frontend/
    src/main/webpack/
        site/
            main.ts
            main.scss

        components/
            product-card/
                product-card.ts
                product-card.scss

            accordion/
                accordion.ts
                accordion.scss

            navigation/
                navigation.ts
                navigation.scss

The site entry point can compose those modules:

typescript
import "../components/product-card/product-card";
import "../components/accordion/accordion";
import "../components/navigation/navigation";

import "./main.scss";

This gives us two useful boundaries.

The source remains component-oriented.

The build system decides the final browser bundles.

Do not confuse source-file organization with Clientlib organization.

Thirty component folders do not require thirty network-loaded Clientlibs.

One Component Does Not Automatically Mean One Clientlib

Creating a separate Clientlib for every component can look architecturally clean because repository boundaries match component boundaries.

At runtime, however, that can create unnecessary delivery complexity.

The opposite extreme is also problematic: one giant global bundle containing code for features almost no page uses.

The right bundle boundary depends on:

  • how often components appear,
  • bundle size,
  • shared dependencies,
  • page composition,
  • caching,
  • loading strategy,
  • build tooling.

Component-oriented source code gives maintainability.

Bundle strategy gives runtime performance.

They should be designed together but not treated as the same decision.

Keep Component JavaScript Small

Not every AEM component needs JavaScript.

A title does not need JavaScript just because it is an AEM component.

A static teaser may only need HTML and CSS.

JavaScript becomes appropriate when the component has browser behavior such as:

  • expanding/collapsing content,
  • carousel interaction,
  • client-side validation,
  • tabs,
  • dynamic filtering,
  • asynchronous requests,
  • user-driven state.

Before creating a JavaScript module, ask whether native HTML/CSS already solves the requirement.

Less JavaScript means less code to download, initialize, debug, and maintain.

HTL Should Produce the Contract JavaScript Needs

JavaScript should not reverse-engineer business state from visual text.

Avoid code such as:

javascript
if (
    element.querySelector(".status").textContent.trim()
        === "Available"
) {
    // ...
}

If the backend state matters to browser behavior, expose an explicit machine-readable value:

html
<article
    class="cmp-product-card"
    data-cmp-is="product-card"
    data-product-status="available">

JavaScript can then read:

javascript
const status = element.dataset.productStatus;

The displayed label can change through translation or content updates without changing the behavioral contract.

This is a clean boundary between Sling Model/HTL output and frontend behavior.

Do Not Serialize the Entire Sling Model Into the DOM

The opposite mistake is to put every backend property into data-* attributes "just in case."

That increases markup size and exposes internal implementation details.

Only expose browser state the browser actually needs.

For larger structured client-side data, use an intentional JSON contract rather than dozens of unrelated attributes.

The browser contract should be smaller than the backend model.

CSS Should Not Know the Repository Structure

Frontend CSS should not depend on AEM repository paths or component resource types.

This is a bad boundary:

css
[data-resource-type="myproject/components/product/card"] {
    ...
}

The browser should style rendered markup, not JCR implementation details.

A resource type can change because of component versioning, proxying, or refactoring.

A stable component class such as:

css
.cmp-product-card {
    ...
}

keeps the browser contract independent of repository implementation.

Component Variations Belong in a Controlled Styling Contract

AEM's Style System allows template authors to expose approved component styles to content authors.

For example, a product card might support:

  • default,
  • featured,
  • compact.

The author chooses a supported variation rather than writing CSS.

The frontend implementation maps the selected style to a known class and provides the corresponding CSS.

The important architectural boundary is:

Authors choose an approved variation; they do not define arbitrary frontend implementation.

This keeps author flexibility inside the design system.

Avoid Global CSS Leakage

Global selectors are one of the easiest ways to make an AEM site fragile.

For example:

css
h2 {
    margin-bottom: 32px;
}

may accidentally affect:

  • teaser headings,
  • dialog-like frontend widgets,
  • footer headings,
  • navigation,
  • Experience Fragment content.

Prefer component scope:

css
.cmp-product-card__title {
    margin-bottom: 1rem;
}

Global CSS should be reserved for deliberate site-wide foundations such as normalization, typography tokens, or shared design primitives.

AEM authors can compose many components in combinations developers did not explicitly test.

CSS isolation makes that composition safer.

Use Design Tokens for Shared Decisions

Component isolation does not mean every component should invent its own spacing and colors.

Shared design decisions can be represented as tokens:

css
:root {
    --spacing-sm: 0.5rem;
    --spacing-md: 1rem;
    --spacing-lg: 2rem;
    --radius-card: 0.5rem;
}

Then the component consumes them:

css
.cmp-product-card {
    padding: var(--spacing-md);
    border-radius: var(--radius-card);
}

This separates site design decisions from component structure.

A design-system update can change shared values without requiring every component selector to be rewritten.

Accessibility Is Part of the Component Contract

Accessibility should not be added after frontend behavior is complete.

If a component opens and closes content, its JavaScript may need to maintain state such as:

html
aria-expanded="true"

If a custom control behaves like a button, the implementation needs keyboard and semantic behavior.

Before replacing native HTML with custom JavaScript, ask whether native elements already provide the required interaction.

Core Components are also useful references here because accessibility is part of their component design, not a separate frontend patch.

Keep Analytics Hooks Separate From Styling

The same separation applies to analytics.

Avoid making analytics depend on a visual selector that may change:

javascript
document.querySelectorAll(".blue-button");

Prefer a stable semantic/data-layer contract.

AEM Core Components integrate with the Adobe Client Data Layer, which is a useful reminder that analytics should consume a deliberate data contract rather than scrape the presentation layer.

The component can change color without changing what business interaction it represents.

Component JavaScript Should Clean Up When Necessary

Not every component needs explicit teardown.

But some do.

A component may register:

  • window-level listeners,
  • document-level listeners,
  • timers,
  • observers,
  • third-party library instances.

If the component can be removed or re-rendered dynamically, those resources can outlive the DOM node.

For simple page-rendered Sites components, this may never become visible.

For authoring-heavy or dynamic applications, it can produce duplicate callbacks and memory leaks.

If initialization allocates external state, define who owns cleanup.

Lifecycle should be intentional rather than accidental.

Avoid Global Mutable State

This pattern creates hidden coupling:

javascript
window.currentProduct = {};
window.productCards = [];

Now one component instance can affect another.

Prefer state owned by the component instance:

javascript
function ProductCard(element) {
    const state = {
        expanded: false
    };

    // behavior using local state
}

Shared application state is sometimes necessary.

When it is, use a deliberate shared service/store boundary rather than turning window into an application container.

Third-Party Libraries Need a Boundary Too

Suppose a carousel component uses a third-party library.

Do not spread the library's initialization calls across HTL and unrelated site scripts.

Keep the dependency behind the component module:

javascript
import Swiper from "swiper";

function Carousel(element) {
    return new Swiper(element, {
        // project configuration
    });
}

Now the rest of the site depends on the Carousel component contract rather than directly on Swiper.

If the library changes later, the migration remains localized.

This also makes it easier to see which component is responsible for the library's bundle weight.

Authoring JavaScript Is Not Site JavaScript

AEM components can have editor-specific Clientlibs for dialog and authoring behavior.

That code should not automatically become part of the public site bundle.

Examples include:

  • dialog show/hide logic,
  • validation used only by authors,
  • authoring event integration,
  • custom dialog widgets.

Public component behavior belongs in site-facing frontend code.

Authoring behavior belongs in editor-specific code.

Core Components themselves follow this separation; their component documentation commonly identifies a normal component Clientlib and a separate .editor category for edit-dialog behavior. citeturn0search0

Keeping those boundaries separate prevents author-only code from increasing the public site's JavaScript cost.

Production Problem — Component Works Only Once Per Page

The first thing to inspect is global selectors.

If the code uses:

javascript
document.querySelector(...)

where multiple instances are possible, only the first component may be initialized.

Initialize from each component root and query inside that root.

Also check for duplicated HTML IDs.

AEM authors can place multiple copies of a component. The frontend implementation has to support that unless the template explicitly prevents it.

Production Problem — Click Fires Twice After Authoring

This usually points to repeated initialization.

Check whether:

  • initialization runs after each authoring refresh,
  • existing instances are initialized again,
  • event listeners are added without an initialized guard,
  • a document-level delegated handler is registered repeatedly.

Do not fix the symptom by adding random removeEventListener calls.

First make the initialization lifecycle explicit and idempotent.

Production Problem — CSS Fixes One Page and Breaks Another

Inspect selector scope and cascade.

Typical causes are:

  • global element selectors,
  • overly broad utility overrides,
  • selectors based on parent page structure,
  • !important used to fight previous leakage,
  • different component nesting.

Move styling ownership back to the component boundary.

If the rule is genuinely global, document it as part of the site design system rather than letting it emerge accidentally from one component stylesheet.

Production Problem — Component Works on Publish but Not in Editor

Check whether the component assumes a one-time page lifecycle.

Also inspect:

  • authoring overlays,
  • DOM replacement after dialog changes,
  • author-only Clientlibs,
  • initialization timing,
  • selectors that accidentally include editor markup.

Keep the normal component implementation editor-agnostic where possible, then isolate AEM editor integration around it.

Production Problem — Large site.js Keeps Growing

Do not immediately split it into one Clientlib per component.

First inspect the build output.

Find:

  • large third-party dependencies,
  • components rarely used,
  • duplicate packages,
  • code that does not need JavaScript,
  • libraries imported globally for one component,
  • opportunities for deliberate code splitting.

The source should already be component-oriented. That makes bundle analysis much easier.

Runtime bundle boundaries should then be chosen from measured usage rather than repository aesthetics.

Testing the Frontend Contract

Component tests should focus on the contract the browser depends on.

For an interactive component, useful tests can cover:

  • initialization with valid markup,
  • multiple instances,
  • missing optional elements,
  • state changes,
  • repeated initialization,
  • keyboard behavior,
  • relevant analytics/data events.

Frontend tests do not replace AEM integration testing.

The final component should still be verified with real HTL output because a frontend module can pass every unit test while HTL renders a different hook or structure.

The Architect View

For each interactive AEM component, I want the following boundaries to be understandable without reading the entire site codebase.

Markup contract: What stable HTML/classes/data attributes does HTL render?

Styling contract: Which selectors belong to this component, and which tokens are shared?

Behavior contract: How does JavaScript discover and initialize the component?

State contract: What browser-side state exists, and who owns it?

Authoring contract: What can authors configure, and what editor-only behavior is required?

Delivery contract: Which source module and runtime bundle deliver the CSS/JavaScript?

Integration contract: Does the component emit analytics or consume third-party libraries?

When these boundaries are explicit, frontend code survives template changes and site growth much better.

A Practical Review Before Production

Take representative components and test them in combinations rather than only on isolated component-demo pages.

Verify:

  • zero, one, and multiple instances,
  • different Style System variations,
  • responsive layouts,
  • authoring insertion and reconfiguration,
  • keyboard interaction,
  • Publish behavior,
  • frontend bundle impact.

A reusable AEM component is not proven because it works once on its development page.

It is proven when authors can compose it with other components without creating hidden frontend dependencies.

Summary

AEM component frontend architecture is not mainly about choosing a JavaScript framework.

It is about defining stable boundaries between HTL, CSS, JavaScript, authoring, and runtime delivery.

Keep frontend source component-oriented even when the build produces shared bundles.

Use scoped styling rather than selectors tied to page structure.

Give JavaScript explicit component roots and behavior hooks.

Support multiple instances.

Make initialization safe to repeat when the lifecycle requires it.

Keep authoring JavaScript out of public runtime bundles.

Expose only the backend state the browser actually needs.

Treat accessibility, analytics, third-party dependencies, and cleanup as part of the component contract.

Once those boundaries are clear, components become easier to move, compose, test, and maintain across a large AEM site.

What's Next

Chapter 36 — AEM + React Integration

The next chapter moves from server-rendered AEM component frontend behavior to the boundary between AEM and React: where React fits, how content/data reaches it, what AEM should own, what React should own, and when adding a React runtime is justified.

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.