For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/custom-component.md.

This guide walks through creating a custom AEM component from start to finish. Every artifact is covered:

Custom AEM Components

This guide walks through creating a custom AEM component from start to finish. Every artifact is covered: component definition, dialog, design dialog, Sling Model, OSGi service, HTL template, dialog clientlib, runtime clientlib, and ui.frontend integration.

The running example is a Featured Article Card component that displays a title, summary, optional image, publication date, and a call-to-action link.

Prerequisites

myproject/
  core/           <-- Java code: Sling Models, OSGi services, servlets
  ui.apps/        <-- AEM components, dialogs, HTL templates, clientlibs
  ui.config/      <-- OSGi configurations (by run mode)
  ui.content/     <-- Sample content, templates, policies
  ui.frontend/    <-- Webpack/Vite build: SCSS, JS, compiled into clientlibs
  all/            <-- Container package that embeds everything

Component File Structure

A complete custom component lives across ui.apps and core:

articlecard/
  .content.xml                          <-- Component definition
  articlecard.html                      <-- HTL template
  _cq_dialog/.content.xml              <-- Author dialog
  _cq_design_dialog/.content.xml       <-- Design dialog (template policy)
  _cq_editConfig.xml                   <-- Edit configuration (optional)
  clientlibs/
    clientlib-editor/                   <-- Dialog clientlib (authoring JS)
      .content.xml
      js.txt
      js/dialog-visibility.js
    clientlib-site/                     <-- Runtime clientlib (CSS/JS)
      .content.xml
      css.txt
      css/articlecard.css
models/
  ArticleCardModel.java                 <-- Sling Model
services/
  DateFormatterService.java             <-- OSGi service interface
  impl/
    DateFormatterServiceImpl.java       <-- OSGi service implementation

Step 1: Component Definition

The .content.xml file registers the component with AEM. There are two approaches.

The preferred way to build components in AEM is to extend an existing Core Component via sling:resourceSuperType. This gives you the parent's dialog fields, rendering logic, accessibility features, and JSON exporter for free. You only override what you need to customise.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          jcr:primaryType="cq:Component"
          jcr:title="Featured Article Card"
          jcr:description="Displays an article with image, title, summary, date, and CTA."
          sling:resourceSuperType="core/wcm/components/teaser/v2/teaser"
          componentGroup="My Project - Content"/>

Why extend?

  • You inherit the parent's dialog, HTL, Sling Model, and export behaviour
  • Your HTL template automatically overrides the parent's (same script name takes priority in /apps)
  • You can add extra dialog fields on top of the inherited ones, or hide fields you don't need with sling:hideChildren (see Dialog Inheritance)
  • Bug fixes and accessibility improvements from Core Component updates are inherited automatically

[!-success] -success tip

Even if your component looks very different from the Core Component you extend, you still benefit from the inherited dialog, JSON export, and data layer. Override only the HTL template and Sling Model.

Approach B: Building from Scratch

When no Core Component matches your use case (e.g. a completely custom interactive widget), build a standalone component without a super type:

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:cq="http://www.day.com/jcr/cq/1.0"
          jcr:primaryType="cq:Component"
          jcr:title="Featured Article Card"
          jcr:description="Displays an article with image, title, summary, date, and CTA."
          componentGroup="My Project - Content"/>

[!-info] -info info

The rest of this guide builds the component from scratch (Approach B) so that every artifact is shown explicitly. If you chose Approach A, you would skip or simplify many of these files because they're inherited.

Step 2: The Dialog

The _cq_dialog defines the author-facing form. This dialog has two tabs: Content and Link Settings.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          xmlns:granite="http://www.adobe.com/jcr/granite/1.0"
          xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
          jcr:primaryType="nt:unstructured"
          jcr:title="Featured Article Card"
          sling:resourceType="cq/gui/components/authoring/dialog">
    <content
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/container">
        <items jcr:primaryType="nt:unstructured">
            <tabs
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/tabs"
                maximized="{Boolean}true">
                <items jcr:primaryType="nt:unstructured">

                    <!-- ==================== Tab 1: Content ==================== -->
                    <content
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Content"
                        sling:resourceType="granite/ui/components/coral/foundation/container"
                        margin="{Boolean}true">
                        <items jcr:primaryType="nt:unstructured">
                            <columns
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns"
                                margin="{Boolean}true">
                                <items jcr:primaryType="nt:unstructured">
                                    <column
                                        jcr:primaryType="nt:unstructured"
                                        sling:resourceType="granite/ui/components/coral/foundation/container">
                                        <items jcr:primaryType="nt:unstructured">
                                            <title
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                fieldLabel="Title"
                                                fieldDescription="The article headline"
                                                name="./title"
                                                required="{Boolean}true"/>
                                            <summary
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/textarea"
                                                fieldLabel="Summary"
                                                fieldDescription="A short description of the article"
                                                name="./summary"
                                                maxlength="{Long}300"/>
                                            <image
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
                                                fieldLabel="Image"
                                                fieldDescription="Select an image from the DAM"
                                                rootPath="/content/dam"
                                                name="./imagePath"/>
                                            <publishDate
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/datepicker"
                                                fieldLabel="Publication Date"
                                                displayedFormat="YYYY-MM-DD"
                                                name="./publishDate"
                                                type="date"
                                                typeHint="Date"/>
                                        </items>
                                    </column>
                                </items>
                            </columns>
                        </items>
                    </content>

                    <!-- ==================== Tab 2: Link Settings ==================== -->
                    <linkSettings
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Link Settings"
                        sling:resourceType="granite/ui/components/coral/foundation/container"
                        margin="{Boolean}true">
                        <items jcr:primaryType="nt:unstructured">
                            <columns
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns"
                                margin="{Boolean}true">
                                <items jcr:primaryType="nt:unstructured">
                                    <column
                                        jcr:primaryType="nt:unstructured"
                                        sling:resourceType="granite/ui/components/coral/foundation/container">
                                        <items jcr:primaryType="nt:unstructured">
                                            <enableLink
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/checkbox"
                                                fieldLabel="Link"
                                                name="./enableLink"
                                                text="Enable CTA link"
                                                uncheckedValue="false"
                                                value="{Boolean}true"/>
                                            <linkUrl
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
                                                fieldLabel="Link URL"
                                                fieldDescription="Internal page or external URL"
                                                rootPath="/content"
                                                name="./linkUrl"
                                                granite:class="cmp-articlecard__dialog-link-url"/>
                                            <linkText
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                fieldLabel="Link Text"
                                                fieldDescription="The CTA button label"
                                                name="./linkText"
                                                emptyText="Read more"
                                                granite:class="cmp-articlecard__dialog-link-text"/>
                                            <openInNewTab
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/checkbox"
                                                name="./openInNewTab"
                                                text="Open in new tab"
                                                uncheckedValue="false"
                                                value="{Boolean}true"
                                                granite:class="cmp-articlecard__dialog-new-tab"/>
                                        </items>
                                    </column>
                                </items>
                            </columns>
                        </items>
                    </linkSettings>

                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

For a reference of all available field types, see Component Dialogs.

Step 3: The Design Dialog (Template Policy)

The _cq_design_dialog defines fields that appear in the template editor under "Edit Template > Policy". These values apply to all instances of the component on pages using that template. Use it for site-wide defaults that authors should not change per instance.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
          jcr:primaryType="nt:unstructured"
          jcr:title="Featured Article Card - Policy"
          sling:resourceType="cq/gui/components/authoring/dialog">
    <content
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/container">
        <items jcr:primaryType="nt:unstructured">
            <tabs
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/tabs"
                maximized="{Boolean}true">
                <items jcr:primaryType="nt:unstructured">
                    <properties
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Settings"
                        sling:resourceType="granite/ui/components/coral/foundation/container"
                        margin="{Boolean}true">
                        <items jcr:primaryType="nt:unstructured">
                            <columns
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns"
                                margin="{Boolean}true">
                                <items jcr:primaryType="nt:unstructured">
                                    <column
                                        jcr:primaryType="nt:unstructured"
                                        sling:resourceType="granite/ui/components/coral/foundation/container">
                                        <items jcr:primaryType="nt:unstructured">
                                            <headingLevel
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/select"
                                                fieldLabel="Heading Level"
                                                fieldDescription="Applied to all article card titles on this template"
                                                name="./headingLevel">
                                                <items jcr:primaryType="nt:unstructured">
                                                    <h2 jcr:primaryType="nt:unstructured" text="H2" value="h2" selected="{Boolean}true"/>
                                                    <h3 jcr:primaryType="nt:unstructured" text="H3" value="h3"/>
                                                    <h4 jcr:primaryType="nt:unstructured" text="H4" value="h4"/>
                                                </items>
                                            </headingLevel>
                                            <maxSummaryLength
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/numberfield"
                                                fieldLabel="Max Summary Length"
                                                fieldDescription="Truncate summaries to this many characters (0 = no limit)"
                                                name="./maxSummaryLength"
                                                min="{Long}0"
                                                max="{Long}500"
                                                value="{Long}150"/>
                                            <showImage
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/checkbox"
                                                name="./showImage"
                                                text="Show image"
                                                checked="{Boolean}true"
                                                uncheckedValue="false"
                                                value="{Boolean}true"/>
                                        </items>
                                    </column>
                                </items>
                            </columns>
                        </items>
                    </properties>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

When to use which dialog:

Regular Dialog (_cq_dialog)Design Dialog (_cq_design_dialog)
ScopePer component instancePer template (all instances)
Edited byContent authorTemplate author / developer
WherePage editor > component > wrench iconTemplate editor > component > policy icon
Use forContent: title, text, links, imagesDefaults: heading level, max items, feature toggles

Step 4: The OSGi Service

Business logic that may be shared across multiple components should live in an OSGi service. For our article card, we create a DateFormatterService that formats publication dates according to a configurable pattern and locale.

Service interface

package com.myproject.core.services;

import java.util.Calendar;

/**
 * Formats dates for display on the frontend.
 */
public interface DateFormatterService {

    /**
     * Formats a Calendar value into a human-readable string.
     *
     * @param date the date to format
     * @return formatted date string, or empty string if date is null
     */
    String format(Calendar date);
}

Service implementation

package com.myproject.core.services.impl;

import com.myproject.core.services.DateFormatterService;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;

@Component(service = DateFormatterService.class)
@Designate(ocd = DateFormatterServiceImpl.Config.class)
public class DateFormatterServiceImpl implements DateFormatterService {

    private static final Logger LOG = LoggerFactory.getLogger(DateFormatterServiceImpl.class);

    @ObjectClassDefinition(name = "Date Formatter Service Configuration")
    @interface Config {

        @AttributeDefinition(
            name = "Date Pattern",
            description = "SimpleDateFormat pattern (e.g. 'MMMM d, yyyy')"
        )
        String datePattern() default "MMMM d, yyyy";

        @AttributeDefinition(
            name = "Locale",
            description = "Locale tag for date formatting (e.g. 'en_US', 'de_DE')"
        )
        String locale() default "en_US";
    }

    private String datePattern;
    private Locale locale;

    @Activate
    protected void activate(Config config) {
        this.datePattern = config.datePattern();
        this.locale = Locale.forLanguageTag(config.locale().replace('_', '-'));
        LOG.debug("DateFormatterService activated with pattern='{}', locale='{}'", datePattern, locale);
    }

    @Override
    public String format(Calendar date) {
        if (date == null) {
            return "";
        }
        try {
            SimpleDateFormat sdf = new SimpleDateFormat(datePattern, locale);
            return sdf.format(date.getTime());
        } catch (IllegalArgumentException e) {
            LOG.warn("Invalid date pattern '{}', falling back to ISO format", datePattern, e);
            return date.toInstant().toString();
        }
    }
}

OSGi configuration

{
    "datePattern": "MMMM d, yyyy",
    "locale": "en_US"
}

You can create run-mode-specific variants:

ui.config/src/main/content/jcr_root/apps/myproject/osgiconfig/
  config/                                <-- all environments (fallback)
    com.myproject.core.services.impl.DateFormatterServiceImpl.cfg.json
  config.author.dev/                     <-- author + dev only
    com.myproject.core.services.impl.DateFormatterServiceImpl.cfg.json

Step 5: The Sling Model

The Sling Model is the backbone of the component. It reads authored properties, calls services, reads policy values, and exposes computed data to the HTL template.

package com.myproject.core.models;

import com.day.cq.wcm.api.policies.ContentPolicy;
import com.day.cq.wcm.api.policies.ContentPolicyManager;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.myproject.core.services.DateFormatterService;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.Default;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Exporter;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;
import com.adobe.cq.export.json.ComponentExporter;

import javax.annotation.PostConstruct;
import java.util.Calendar;

@Model(
    adaptables = SlingHttpServletRequest.class,
    adapters = {ArticleCardModel.class, ComponentExporter.class},
    resourceType = ArticleCardModel.RESOURCE_TYPE,
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
@Exporter(name = "jackson", extensions = "json")
public class ArticleCardModel implements ComponentExporter {

    static final String RESOURCE_TYPE = "myproject/components/articlecard";

    // ──────────────────────────── Dialog properties ────────────────────────────

    @ValueMapValue
    private String title;

    @ValueMapValue
    private String summary;

    @ValueMapValue
    private String imagePath;

    @ValueMapValue
    private Calendar publishDate;

    @ValueMapValue
    @Default(booleanValues = false)
    private boolean enableLink;

    @ValueMapValue
    private String linkUrl;

    @ValueMapValue
    private String linkText;

    @ValueMapValue
    @Default(booleanValues = false)
    private boolean openInNewTab;

    // ──────────────────────────── Injected services ────────────────────────────

    @OSGiService
    private DateFormatterService dateFormatterService;

    @SlingObject
    private Resource resource;

    // ──────────────────────────── Policy (design dialog) values ────────────────

    private String headingLevel;
    private int maxSummaryLength;
    private boolean showImage;

    @PostConstruct
    protected void init() {
        // Read template policy values
        ContentPolicyManager policyManager = resource.getResourceResolver()
            .adaptTo(ContentPolicyManager.class);
        if (policyManager != null) {
            ContentPolicy policy = policyManager.getPolicy(resource);
            if (policy != null) {
                headingLevel = policy.getProperties().get("headingLevel", "h2");
                maxSummaryLength = policy.getProperties().get("maxSummaryLength", 150);
                showImage = policy.getProperties().get("showImage", true);
            }
        }
        // Apply defaults if policy not found
        if (headingLevel == null) {
            headingLevel = "h2";
        }
    }

    // ──────────────────────────── Getters (used by HTL) ───────────────────────

    @JsonProperty("title")
    public String getTitle() {
        return title;
    }

    public String getSummary() {
        if (StringUtils.isBlank(summary)) {
            return "";
        }
        if (maxSummaryLength > 0 && summary.length() > maxSummaryLength) {
            return summary.substring(0, maxSummaryLength).trim() + "\u2026";
        }
        return summary;
    }

    public String getImagePath() {
        return imagePath;
    }

    public boolean isShowImage() {
        return showImage && StringUtils.isNotBlank(imagePath);
    }

    public String getFormattedDate() {
        return dateFormatterService != null ? dateFormatterService.format(publishDate) : "";
    }

    public boolean isEnableLink() {
        return enableLink && StringUtils.isNotBlank(linkUrl);
    }

    public String getLinkUrl() {
        return linkUrl;
    }

    public String getLinkText() {
        return StringUtils.defaultIfBlank(linkText, "Read more");
    }

    public boolean isOpenInNewTab() {
        return openInNewTab;
    }

    public String getHeadingLevel() {
        return headingLevel;
    }

    public boolean isEmpty() {
        return StringUtils.isBlank(title);
    }

    @Override
    public String getExportedType() {
        return RESOURCE_TYPE;
    }
}

Key patterns used:

PatternAnnotation / APIPurpose
Dialog values@ValueMapValueReads properties from the component's jcr:content node
Default values@Default(booleanValues = false)Fallback when property is missing
OSGi service@OSGiServiceInjects the DateFormatterService
Policy valuesContentPolicyManagerReads design dialog values from the template policy
JSON export@Exporter + ComponentExporterEnables .model.json output

Step 6: The HTL Template

The HTL template renders the Sling Model's data into HTML. It follows BEM naming for CSS classes.

<sly data-sly-use.model="com.myproject.core.models.ArticleCardModel"/>
<sly data-sly-use.clientlib="/libs/granite/sightly/templates/clientlib.html"/>

<sly data-sly-test.hasContent="${!model.empty}">
    <article class="cmp-articlecard">

        <!-- Image -->
        <div data-sly-test="${model.showImage}" class="cmp-articlecard__image">
            <img src="${model.imagePath @ context='uri'}"
                 alt="${model.title @ context='attribute'}"
                 loading="lazy"/>
        </div>

        <!-- Content -->
        <div class="cmp-articlecard__content">

            <!-- Title with configurable heading level from design dialog -->
            <h2 data-sly-element="${model.headingLevel}"
                class="cmp-articlecard__title">
                ${model.title}
            </h2>

            <!-- Publication date -->
            <time data-sly-test="${model.formattedDate}"
                  class="cmp-articlecard__date">
                ${model.formattedDate}
            </time>

            <!-- Summary -->
            <p data-sly-test="${model.summary}"
               class="cmp-articlecard__summary">
                ${model.summary}
            </p>

            <!-- CTA link -->
            <a data-sly-test="${model.enableLink}"
               class="cmp-articlecard__cta"
               href="${model.linkUrl @ context='uri'}"
               target="${model.openInNewTab ? '_blank' : ''}"
               rel="${model.openInNewTab ? 'noopener noreferrer' : ''}">
                ${model.linkText}
            </a>
        </div>

    </article>
</sly>

<!-- Author-mode placeholder when component is empty -->
<sly data-sly-test="${!hasContent && wcmmode.edit}">
    <div class="cq-placeholder" data-emptytext="Featured Article Card: click to configure"></div>
</sly>

<!-- Load component clientlib -->
<sly data-sly-call="${clientlib.css @ categories='myproject.components.articlecard'}"/>

Key techniques used:

  • data-sly-element="${model.headingLevel}" - dynamic heading level from design dialog
  • @ context='uri' - XSS-safe URL rendering
  • @ context='attribute' - XSS-safe attribute rendering
  • data-sly-test - conditional rendering (image, date, link)
  • wcmmode.edit - author-mode placeholder for empty components
  • clientlib.css - loads the component's runtime CSS

Step 7: The Dialog Clientlib

A dialog clientlib contains JavaScript that runs inside the Touch UI dialog (not on the published page). Use it for show/hide logic, custom validation, or dynamic behaviour in the authoring form.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          jcr:primaryType="cq:ClientLibraryFolder"
          categories="[cq.authoring.dialog]"
          allowProxy="{Boolean}true"/>
#base=js
dialog-visibility.js

This JS toggles the visibility of link URL, link text, and new-tab checkbox based on the "Enable CTA link" checkbox:

(function ($document) {
    'use strict';

    var CHECKBOX_SELECTOR = '[name="./enableLink"]';
    var LINK_FIELDS_SELECTORS = [
        '.cmp-articlecard__dialog-link-url',
        '.cmp-articlecard__dialog-link-text',
        '.cmp-articlecard__dialog-new-tab'
    ].join(',');

    function toggleLinkFields() {
        var checkbox = document.querySelector(CHECKBOX_SELECTOR);
        if (!checkbox) return;

        var isChecked = checkbox.checked;
        document.querySelectorAll(LINK_FIELDS_SELECTORS).forEach(function (el) {
            el.hidden = !isChecked;
        });
    }

    // Run on dialog open and when the checkbox changes
    $document.on('dialog-ready', toggleLinkFields);
    $document.on('change', CHECKBOX_SELECTOR, toggleLinkFields);

})(jQuery(document));

[!-info] -info info

The cq.authoring.dialog category is automatically loaded by AEM when any Touch UI dialog is opened. This means your JS runs for all dialogs on the instance. Use specific selectors (like the granite:class values set in the dialog XML) to scope your logic to this component only.

For more show/hide patterns, see Component Dialogs - Dynamic show/hide.

Step 8: The Component Clientlib (Runtime CSS/JS)

This clientlib is loaded on the published page and contains the component's styles and optional JavaScript.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          jcr:primaryType="cq:ClientLibraryFolder"
          categories="[myproject.components.articlecard]"
          allowProxy="{Boolean}true"/>
#base=css
articlecard.css
.cmp-articlecard {
    display: flex;
    flex-direction: column;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    overflow: hidden;
    transition: box-shadow 0.2s ease;
}

.cmp-articlecard:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.cmp-articlecard__image img {
    width: 100%;
    height: 200px;
    object-fit: cover;
    display: block;
}

.cmp-articlecard__content {
    padding: 1.25rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.cmp-articlecard__title {
    margin: 0;
    font-size: 1.25rem;
    line-height: 1.3;
}

.cmp-articlecard__date {
    font-size: 0.85rem;
    color: #666;
}

.cmp-articlecard__summary {
    margin: 0;
    color: #333;
    line-height: 1.5;
}

.cmp-articlecard__cta {
    display: inline-block;
    margin-top: 0.5rem;
    padding: 0.5rem 1rem;
    background-color: #0055ff;
    color: #fff;
    text-decoration: none;
    border-radius: 4px;
    font-weight: 600;
    align-self: flex-start;
    transition: background-color 0.2s ease;
}

.cmp-articlecard__cta:hover {
    background-color: #003ecb;
}

[!-success] -success tip

Inline clientlibs vs ui.frontend: For small components with minimal CSS, inline clientlibs (like above) are simple and self-contained. For larger projects with SCSS, TypeScript, or shared design tokens, use the ui.frontend module (see next section). Both approaches can coexist.

Frontend via ui.frontend Module

The ui.frontend module provides a modern build pipeline (webpack or Vite) that compiles SCSS, TypeScript/ES6+, and other assets into production-ready clientlibs. This is the recommended approach for projects with a dedicated frontend team or complex design system requirements.

How it works

ui.frontend/src (SCSS, JS)

webpack / Vite build

ui.frontend/dist (CSS, JS)

aem-clientlib-generator

ui.apps/clientlibs (AEM clientlib)

  1. Source files (SCSS, JS/TS) live in ui.frontend/src/
  2. The build tool (webpack by default) compiles and bundles them into ui.frontend/dist/
  3. aem-clientlib-generator copies the output into ui.apps/src/main/content/jcr_root/apps/myproject/clientlibs/
  4. Maven packages everything into the content package for deployment

Directory structure

components/
  _articlecard.scss                <-- Component-specific SCSS
  articlecard.js                   <-- Component-specific JS (optional)
site/
  main.scss                        <-- Main SCSS entry (imports all components)
  main.js                          <-- Main JS entry (imports all components)

Component SCSS

.cmp-articlecard {
    display: flex;
    flex-direction: column;
    border: 1px solid $color-border;       // shared design token
    border-radius: $border-radius-md;
    overflow: hidden;
    transition: box-shadow 0.2s ease;

    &:hover {
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }

    &__image img {
        width: 100%;
        height: 200px;
        object-fit: cover;
        display: block;
    }

    &__content {
        padding: $spacing-md;
        display: flex;
        flex-direction: column;
        gap: $spacing-sm;
    }

    &__title {
        margin: 0;
        font-size: $font-size-lg;
        line-height: 1.3;
    }

    &__date {
        font-size: $font-size-sm;
        color: $color-text-muted;
    }

    &__summary {
        margin: 0;
        line-height: 1.5;
    }

    &__cta {
        @include button-primary;            // shared mixin
        align-self: flex-start;
        margin-top: $spacing-sm;
    }
}

Import into main entry

// Base and utilities
@import '../base/variables';
@import '../base/mixins';
@import '../base/reset';

// Components
@import '../components/articlecard';
// ... other components
// Component JS (if any interactive behaviour is needed)
import '../components/articlecard.js';

Clientlib generator config

The clientlib.config.js in the ui.frontend root maps build output to AEM clientlib categories:

module.exports = [
    {
        name: 'clientlib-site',
        categories: ['myproject.site'],
        serializationFormat: 'xml',
        cssProcessor: ['default:none', 'min:none'],    // already minified by webpack
        jsProcessor: ['default:none', 'min:none'],
        assets: {
            js: ['dist/**/*.js'],
            css: ['dist/**/*.css'],
        },
    },
    // Separate clientlib for dependencies (vendor libs) if needed
    {
        name: 'clientlib-dependencies',
        categories: ['myproject.dependencies'],
        serializationFormat: 'xml',
        assets: {
            js: ['dist/vendors~*.js'],
        },
    },
];

Build commands

# Full build + deploy to local AEM
mvn clean install -PautoInstallSinglePackage

# Frontend-only build (faster iteration)
cd ui.frontend
npm run dev          # watch mode with hot-reload (if configured)
npm run build        # production build

# Deploy only ui.apps after a frontend build
cd ../ui.apps
mvn clean install -PautoInstallPackage

When to use which approach

Inline component clientlibsui.frontend module
Best forSmall projects, few components, plain CSS/JSDesign systems, SCSS, TypeScript, shared tokens
Build toolNone (raw CSS/JS deployed as-is)webpack, Vite, or similar
Shared variablesNot possible (plain CSS)SCSS variables, mixins, design tokens
TeamFull-stack developerDedicated frontend developer(s)
Hot reloadNo (manual deploy)Yes (with npm run dev)
Can coexist?Yes - dialog clientlibs are always inlineYes - runtime clientlibs can come from either

[!-success] -success tip

For most projects, use ui.frontend for the main site bundle (compiled SCSS + JS shared across all components) and inline clientlibs only for dialog-specific authoring JS. This gives you the best of both worlds: a modern build pipeline for production CSS/JS and simple inline scripts for authoring UX.

Still on webpack? @aemvite is a drop-in Vite + esbuild toolchain for exactly this module - it replaces webpack, Babel, PostCSS, ESLint, and aem-clientlib-generator while keeping the emitted clientlib descriptors byte-identical to the archetype's historical output.

Unit Testing the Sling Model

Test your model with wcm.io AEM Mocks or the AEM SDK testing utilities.

package com.myproject.core.models;

import com.myproject.core.services.DateFormatterService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.Calendar;
import java.util.GregorianCalendar;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@ExtendWith(AemContextExtension.class)
class ArticleCardModelTest {

    private final AemContext context = new AemContext();

    private DateFormatterService dateFormatterService;

    @BeforeEach
    void setUp() {
        // Register a mock OSGi service
        dateFormatterService = mock(DateFormatterService.class);
        when(dateFormatterService.format(any(Calendar.class))).thenReturn("March 15, 2025");
        context.registerService(DateFormatterService.class, dateFormatterService);
    }

    @Test
    void testGetTitle() {
        // Set up content
        context.create().resource("/content/page/jcr:content/articlecard",
            "sling:resourceType", ArticleCardModel.RESOURCE_TYPE,
            "title", "Test Article",
            "summary", "A short summary for testing purposes.",
            "publishDate", new GregorianCalendar(2025, Calendar.MARCH, 15)
        );

        context.currentResource("/content/page/jcr:content/articlecard");

        // Adapt to model
        ArticleCardModel model = context.request().adaptTo(ArticleCardModel.class);

        assertNotNull(model);
        assertEquals("Test Article", model.getTitle());
        assertEquals("A short summary for testing purposes.", model.getSummary());
        assertEquals("March 15, 2025", model.getFormattedDate());
        assertFalse(model.isEmpty());
    }

    @Test
    void testEmptyComponent() {
        context.create().resource("/content/page/jcr:content/empty",
            "sling:resourceType", ArticleCardModel.RESOURCE_TYPE
        );
        context.currentResource("/content/page/jcr:content/empty");

        ArticleCardModel model = context.request().adaptTo(ArticleCardModel.class);

        assertNotNull(model);
        assertTrue(model.isEmpty());
    }
}

Component Registration and Allowed Components

Component group

The componentGroup property in .content.xml determines where the component appears in the editor's component browser:

componentGroup="My Project - Content"

Components without a componentGroup (or with the group set to .hidden) are not shown in the browser but can still be used programmatically or via template policies.

Configuring allowed components in template policies

In the template editor:

  1. Open a template > Structure mode
  2. Click the policy icon on a layout container
  3. Under Allowed Components, check your component's group ("My Project - Content")
  4. Save

This is stored in the repository under /conf/myproject/settings/wcm/policies/.

Editable vs static templates

Editable templatesStatic templates (legacy)
Component policiesManaged in template editor UIHardcoded in design mode
Allowed componentsPer-container policycq:allowedTemplates / design cells
RecommendationAlways use for new projectsOnly maintain for legacy projects

Edit Configuration (Optional)

The _cq_editConfig.xml lets you customise the editor behaviour (drag-and-drop, in-place editing, action toolbars):

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
          cq:actions="[edit,delete,insert,copymove]"
          cq:dialogMode="floating"
          cq:layout="editbar"
          jcr:primaryType="cq:EditConfig">
    <cq:dropTargets jcr:primaryType="nt:unstructured">
        <image
            jcr:primaryType="cq:DropTargetConfig"
            accept="[image/.*]"
            groups="[media]"
            propertyName="./imagePath"/>
    </cq:dropTargets>
</jcr:root>

This enables drag-and-drop of images from the asset finder directly onto the component, setting the imagePath property automatically.

Complete File Reference

Here is every file created for the Featured Article Card, for quick reference:

ui.apps/src/main/content/jcr_root/apps/myproject/components/articlecard/
  .content.xml                                    Component definition
  articlecard.html                                 HTL template
  _cq_dialog/.content.xml                         Author dialog (2 tabs)
  _cq_design_dialog/.content.xml                  Design dialog (template policy)
  _cq_editConfig.xml                              Drag-and-drop config
  clientlibs/
    clientlib-editor/.content.xml                  Dialog clientlib definition
    clientlib-editor/js.txt                        JS file list
    clientlib-editor/js/dialog-visibility.js       Show/hide logic for link fields
    clientlib-site/.content.xml                    Runtime clientlib definition
    clientlib-site/css.txt                         CSS file list
    clientlib-site/css/articlecard.css              Component styles

core/src/main/java/com/myproject/core/
  models/ArticleCardModel.java                     Sling Model
  services/DateFormatterService.java               OSGi service interface
  services/impl/DateFormatterServiceImpl.java      OSGi service implementation

core/src/test/java/com/myproject/core/
  models/ArticleCardModelTest.java                 Unit test

ui.config/src/main/content/jcr_root/apps/myproject/osgiconfig/
  config/
    com.myproject.core.services.impl.DateFormatterServiceImpl.cfg.json

ui.frontend/src/main/webpack/
  components/_articlecard.scss                      SCSS (if using ui.frontend)
  components/articlecard.js                         JS (if using ui.frontend)