AEM Component Development: The Complete Tutorial

14 min read

End-to-end AEM component development — component anatomy, Touch UI dialogs, multifields, Sling Models, HTL best practices, OSGi services, Sling Servlets, and clientlibs. With a full component cheat sheet, best practices, and do's & don'ts.

AEMComponentsSling ModelsHTLOSGiTutorial
AEM Component Development: The Complete Tutorial

Components are the heart of AEM. Almost everything an author drags onto a page is a component, and almost everything you build as a developer is one too. The reason component development can feel intimidating at first is that a single component quietly touches every layer of the platform at once: it has a content structure in the JCR, an authoring dialog rendered by Granite UI, a Sling Model that holds its logic, an HTL script that produces its markup, and often a client library for styling — with an OSGi service or a Sling Servlet behind it when it needs data.

This tutorial walks that entire chain from end to end, with real, copy-paste-ready code at each step and an explanation of why each piece exists. By the end you'll be able to build a complete, production-quality component and understand how its parts fit together. The article finishes with a cheat sheet, best practices, and a do's-and-don'ts list you can keep nearby.

If you're new to the platform, read the AEM Developer Cheat Sheet first for the architecture and vocabulary. The Annotations reference and HTL cheat sheet go deeper on the Java and templating details touched on here. And to generate the boilerplate below in seconds, try my AEM Component Generator.

Anatomy of a component

Before writing any code, it helps to see what a component is on disk. A component is simply a node of type cq:Component under /apps/<project>/components/, and the files inside it each play a specific role:

/apps/mysite/components/teaser
├── .content.xml            # cq:Component: jcr:title, componentGroup, sling:resourceSuperType
├── teaser.html             # HTL script (entry point — matches component name)
├── _cq_dialog/.content.xml # cq:dialog — the author edit dialog (Touch UI)
├── _cq_editConfig.xml      # cq:editConfig — edit bar, drop targets, listeners
└── _cq_design_dialog/      # cq:design_dialog — design/policy dialog (optional)

The file that makes everything work is the HTL script — and the property that connects content to that script is sling:resourceType. When a page references the resource type mysite/components/teaser, Sling looks in that folder and renders teaser.html. That single link is the whole reason your component appears on a page. (If the term "resource type" is new, the request-resolution section of the cheat sheet explains how Sling uses it.)

Here is the component node itself, defined in .content.xml:

<?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:Component"
          jcr:title="Teaser"
          componentGroup="My Site - Content"
          sling:resourceSuperType="core/wcm/components/teaser/v2/teaser"/>

jcr:title is the name authors see in the component browser, componentGroup controls which group it appears under, and sling:resourceSuperType — which we'll come back to next — is what makes this component inherit from an Adobe Core Component.

The proxy pattern (use Core Components)

The most important habit in modern AEM development is to not reinvent the wheel. Adobe ships a library of open-source Core Components (Teaser, Image, Text, Title, List, and many more) that already handle accessibility, the analytics data layer, and JSON export for headless delivery. Rather than building those from scratch, you create a thin proxy component that inherits one through the sling:resourceSuperType property you just saw, and customize only the parts you actually need.

The payoff is significant: you get all of the Core Component's behavior for free, you decide exactly which version you're pinned to, and because your content references your resource type, upgrading the underlying library never breaks existing pages. Think of the proxy as your own branded shell wrapped around battle-tested Adobe code.

Touch UI dialogs

A component is only useful if authors can configure it, and that's the job of the dialog. The dialog lives in _cq_dialog/.content.xml and is built from Granite UI form components, organized into tabs. Every field you add becomes a value an author can edit, stored back into the component's content node.

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
          jcr:primaryType="nt:unstructured"
          jcr:title="Teaser"
          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">
        <items jcr:primaryType="nt:unstructured">
          <properties jcr:primaryType="nt:unstructured" jcr:title="Properties"
                      sling:resourceType="granite/ui/components/coral/foundation/container">
            <items jcr:primaryType="nt:unstructured">
              <heading jcr:primaryType="nt:unstructured"
                       sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                       fieldLabel="Heading" name="./heading"/>
              <link jcr:primaryType="nt:unstructured"
                    sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
                    fieldLabel="Link" name="./link" rootPath="/content"/>
            </items>
          </properties>
        </items>
      </tabs>
    </items>
  </content>
</jcr:root>

The nesting looks heavy, but the pattern is always the same: a container holds tabs, each tab holds items, and each item is a field with a sling:resourceType that decides what kind of input it is. The two fields above give the author a text field for a heading and a path browser for a link.

Common field resource types

You'll reach for the same handful of fields constantly. Keep this list handy:

Fieldsling:resourceType
Text fieldgranite/ui/components/coral/foundation/form/textfield
Text area.../form/textarea
Rich text (RTE)cq/gui/components/authoring/dialog/richtext
Path browser.../form/pathfield
Dropdown.../form/select
Checkbox.../form/checkbox
Number.../form/numberfield
Date picker.../form/datepicker
Image / filecq/gui/components/authoring/dialog/fileupload
Multifieldgranite/ui/components/coral/foundation/form/multifield

The detail that ties a field to stored content is the name attribute. A value of ./heading means "save this field into the heading property of the component's own content node." That leading ./ is relative to the resource being edited, and it's how a dialog field and a Sling Model field end up referring to the same property.

Multifields (repeating groups)

Single fields cover most cases, but sooner or later an author needs to add a list of things — several links, a set of stats, a list of FAQ items. That's what a composite multifield is for: it lets the author add, remove, and reorder a repeating group of fields, and it stores each entry as its own child node. You enable it with composite="{Boolean}true" and a nested field describing one entry:

<links jcr:primaryType="nt:unstructured"
       sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
       composite="{Boolean}true" fieldLabel="Links">
  <field jcr:primaryType="nt:unstructured"
         sling:resourceType="granite/ui/components/coral/foundation/container"
         name="./links">
    <items jcr:primaryType="nt:unstructured">
      <text jcr:primaryType="nt:unstructured"
            sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
            fieldLabel="Text" name="./text"/>
      <url jcr:primaryType="nt:unstructured"
           sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
           fieldLabel="URL" name="./url"/>
    </items>
  </field>
</links>

When the author saves, AEM creates child nodes links/item0, links/item1, and so on, each holding a text and a url property. To read that list back in Java, you inject it as a List<Resource> with the @ChildResource annotation — which brings us to the logic layer.

Sling Models: the logic layer

So far we have content (the dialog values) but no behavior. A Sling Model is the Java class that bridges the two: it adapts a Resource (or a SlingHttpServletRequest) into a clean, typed object that your template can read through simple getters. The golden rule is that all logic belongs here — never in the template.

package com.mysite.core.models;

import javax.annotation.PostConstruct;
import java.util.List;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;
import org.apache.sling.models.annotations.injectorspecific.ChildResource;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;

@Model(
    adaptables = Resource.class,
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class TeaserModel {

    @ValueMapValue
    private String heading;

    @ValueMapValue
    private String link;

    @ChildResource
    private List<Resource> links;   // composite multifield items

    @OSGiService
    private MyService myService;

    private String computedTitle;

    @PostConstruct
    protected void init() {
        computedTitle = heading != null ? heading.toUpperCase() : "";
    }

    public String getHeading() { return heading; }
    public String getLink() { return link; }
    public List<Resource> getLinks() { return links; }
    public String getComputedTitle() { return computedTitle; }
}

Notice how the annotations map directly onto the dialog you wrote. The heading and link fields are populated from the matching JCR properties, the links list is populated from the multifield's child nodes, and the init() method runs after injection to compute a derived value. The annotations doing the work are worth memorizing (the Annotations reference covers every one in detail):

  • @Model declares the class as a model and sets defaultInjectionStrategy = OPTIONAL, so a missing property leaves the field null instead of failing construction.
  • @ValueMapValue reads a property from the resource.
  • @ChildResource reads a child node, or a List of them for multifields.
  • @Self and @Via let you adapt the current object or delegate to a Core Component's model through ResourceSuperType.
  • @OSGiService injects a service so the model can call business logic.
  • @PostConstruct marks a method to run once all injection is complete — the right place for any computation.

Tip: Always set defaultInjectionStrategy = OPTIONAL and write null-safe getters. The default strategy is REQUIRED, which throws if any field can't be injected — and on real pages, optional dialog fields are frequently empty.

Exporting to JSON (SPA / headless)

If your component needs to feed a Single-Page App or a headless front end, you don't write a separate API — you add the Jackson exporter to the same model, and AEM serializes it to JSON automatically:

@Model(adaptables = Resource.class,
       adapters = {TeaserModel.class, ComponentExporter.class},
       resourceType = "mysite/components/teaser",
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
@Exporter(name = "jackson", extension = "json")
public class TeaserModel implements ComponentExporter { /* ... */ }

With this in place, requesting the component's path with a .model.json extension returns its data as JSON — the same model powering both the server-rendered page and the headless API.

HTL: the markup layer

The final rendering step is HTL, AEM's templating language. Its job is narrow on purpose: bind the model, output values, and produce markup — with automatic, context-aware escaping that prevents cross-site scripting. Logic stays in the model; HTL only presents it.

<sly data-sly-use.teaser="com.mysite.core.models.TeaserModel"/>

<div class="cmp-teaser">
    <h2 class="cmp-teaser__title">${teaser.computedTitle}</h2>

    <a class="cmp-teaser__link" href="${teaser.link}"
       data-sly-test="${teaser.link}">Read more</a>

    <ul class="cmp-teaser__links" data-sly-list.item="${teaser.links}">
        <li><a href="${item.url}">${item.text}</a></li>
    </ul>
</div>

Reading top to bottom: data-sly-use binds the Sling Model to the variable teaser; ${teaser.computedTitle} outputs the computed value (calling getComputedTitle()); data-sly-test renders the link only when there is one; and data-sly-list iterates the multifield items. A few statements cover the vast majority of templates (the HTL cheat sheet is the full reference):

  • data-sly-use — bind a Sling Model or Use-API object.
  • data-sly-test — render conditionally.
  • data-sly-list / data-sly-repeat — iterate.
  • data-sly-resource / data-sly-include — include other resources or scripts.
  • data-sly-template / data-sly-call — define and reuse markup fragments.
  • ${ } — expressions, automatically escaped based on where they appear.

OSGi services and Sling Servlets

Components that need shared business logic or server-side data reach for two more building blocks: OSGi services for reusable logic, and Sling Servlets for HTTP endpoints.

A service with configuration

A service is a plain Java class registered with OSGi so any model or servlet can inject it. Adding a configuration class lets you change its behavior from the OSGi console without redeploying:

@Component(service = MyService.class, immediate = true)
@Designate(ocd = MyService.Config.class)
public class MyServiceImpl implements MyService {

    @ObjectClassDefinition(name = "My Service Config")
    public @interface Config {
        @AttributeDefinition(name = "API endpoint")
        String endpoint() default "https://api.example.com";
    }

    private String endpoint;

    @Activate
    @Modified
    protected void activate(Config config) {
        this.endpoint = config.endpoint();
    }

    @Override
    public String getEndpoint() { return endpoint; }
}

The @Activate plus @Modified pair is a small but important detail: it means a configuration change is picked up immediately, without restarting the component.

A Sling Servlet

When the browser needs to call the server directly — for an AJAX request or a JSON feed — you register a servlet. The preferred way is to bind it to a resource type so it inherits AEM's access control:

@Component(service = Servlet.class)
@SlingServletResourceTypes(
    resourceTypes = "mysite/components/teaser",
    selectors = "data",
    extensions = "json",
    methods = HttpConstants.METHOD_GET)
public class TeaserDataServlet extends SlingSafeMethodsServlet {
    @Override
    protected void doGet(SlingHttpServletRequest req, SlingHttpServletResponse resp)
            throws IOException {
        resp.setContentType("application/json");
        resp.getWriter().write("{\"ok\":true}");
    }
}

Important: Prefer resource-type-bound servlets over path-bound ones. A path-bound servlet (registered at a fixed URL like /bin/...) bypasses resource-level access control and has to be explicitly allowed through the Dispatcher — a common security gap. Bind to a resource type unless you have a specific reason not to.

Client libraries

The last piece is the front end. CSS and JavaScript live in a client library — a cq:ClientLibraryFolder node that bundles, minifies, and versions your assets:

/apps/mysite/clientlibs/teaser
├── .content.xml   # categories, dependencies, embed, allowProxy
├── css.txt        # lists .css/.scss files to bundle
├── js.txt         # lists .js files to bundle
├── css/teaser.scss
└── js/teaser.js

The folder's metadata declares its category (the name you include it by) and its dependencies:

<jcr:root jcr:primaryType="cq:ClientLibraryFolder"
          categories="[mysite.teaser]"
          dependencies="[mysite.base]"
          allowProxy="{Boolean}true"/>

You then pull the library into a page or component from HTL by calling the built-in clientlib template:

<sly data-sly-use.clientlib="/libs/granite/sightly/templates/clientlib.html"
     data-sly-call="${clientlib.css @ categories='mysite.teaser'}"/>

Always serve client libraries through the /etc.clientlibs/... proxy path with allowProxy=true. This delivers your CSS and JS without ever exposing the /apps tree to the Dispatcher — a small setting with a real security benefit.

The full flow, in order

Step back and the whole component is just a short pipeline, each stage feeding the next:

  1. Component node with a sling:resourceType (ideally a proxy to a Core Component).
  2. Dialog captures author input and writes it to JCR properties.
  3. Sling Model reads those properties and child nodes, and runs the logic.
  4. HTL binds the model and renders escaped markup.
  5. Client library styles the markup and adds any behavior.
  6. Servlet or service (optional) supplies AJAX data or business logic.

Internalize this loop — dialog → model → HTL → clientlib — and every component you ever build is a variation on it.

Component cheat sheet

Component node (.content.xml)

jcr:primaryType="cq:Component"
jcr:title="..."  componentGroup="..."
sling:resourceSuperType="core/wcm/components/.../vN/..."

Sling Model annotations

AnnotationUse
@Model(adaptables=…, defaultInjectionStrategy=OPTIONAL)Declare model
@ValueMapValueJCR property
@ChildResourceChild node / multifield list
@Self + @Via(ResourceSuperType.class)Delegate to Core model
@OSGiServiceInject service
@ScriptVariableHTL global (e.g. currentPage)
@PostConstructPost-injection logic
@Exporter(name="jackson", extension="json")JSON export

HTL quick reference

data-sly-use.x="...Model"
data-sly-test.cond="${...}"
data-sly-list.item="${items}"  <!-- itemList.index/first/last -->
data-sly-resource="${path @ resourceType='...'}"
data-sly-template.tpl="${@ a}"  /  data-sly-call="${tpl @ a='x'}"
${value @ context='html'}

Servlet registration

@SlingServletResourceTypes(resourceTypes="...", selectors="data",
    extensions="json", methods=HttpConstants.METHOD_GET)

Best practices

  • Extend Core Components through the proxy pattern rather than building from scratch.
  • ✅ Keep all logic in Sling Models and none in HTL.
  • ✅ Use defaultInjectionStrategy = OPTIONAL with null-safe getters.
  • ✅ Configure components with editable templates, policies, and the Style System.
  • ✅ Use resource-type-bound servlets and dedicated service users — never an admin resolver.
  • ✅ Write BEM-style CSS, accessible markup, and i18n strings.
  • ✅ Serve client libraries via /etc.clientlibs with allowProxy.

Do's and Don'ts

Do

  • ✅ Reuse Core Components and the analytics data layer.
  • ✅ Make dialogs accessible with proper labels and descriptions.
  • ✅ Add a JSON @Exporter if SPA or headless delivery is on the roadmap.

Don't

  • ❌ Don't put business logic, queries, or service calls in HTL.
  • ❌ Don't call getResourceResolver() with admin rights — use a service user.
  • ❌ Don't bind servlets to broad paths (a security and Dispatcher risk).
  • ❌ Don't hardcode user-facing strings — use i18n.
  • ❌ Don't modify /libs; overlay what you need in /apps.

Wrapping up

A great AEM component is small and focused: it delegates to a Core Component, keeps its logic in a Sling Model, and renders safe, escaped markup with HTL. Once the dialog → model → HTL → clientlib loop is second nature, everything else — multifields, servlets, exporters — is just a variation you slot into the same flow.

From here, deepen the two layers this tutorial leaned on: the Annotations reference for the Java side, and the HTL cheat sheet for templating. And to skip the boilerplate entirely, scaffold dialogs and models with my AEM Component Generator and Content Fragment Model generator.

Share this article

Subscribe to the Newsletter

Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

Back to Blog