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.
Approach A: Extending a Core Component (Recommended)
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:
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 () + " \u 2026 " ;
}
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:
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)
Source files (SCSS, JS/TS) live in ui.frontend/src/
The build tool (webpack by default) compiles and bundles them into ui.frontend/dist/
aem-clientlib-generator copies the output into ui.apps/src/main/content/jcr_root/apps/myproject/clientlibs/
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
[!-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 :
Open a template > Structure mode
Click the policy icon on a layout container
Under Allowed Components , check your component's group ("My Project - Content")
Save
This is stored in the repository under /conf/myproject/settings/wcm/policies/.
Editable vs static templates
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)