N
Naveenr.dev
Chapter 06
15 min read2026-06-18

AEM React Component Development Complete Guide

A complete guide for building authorable React components in AEM, from dialog creation to Sling Model export and React mapping.

Content Objective

  • Understand the AEM SPA React component flow
  • Create a dialog for author input
  • Write a Sling Model with JSON exporter
  • Map the AEM component in the React app
  • Deploy and validate the component in AEM

Introduction

Adobe Experience Manager (AEM) supports React components through the SPA Editor. This means you can build authorable React components that content editors configure in AEM while React renders the final experience.

The core idea is:

  • AEM holds the component structure and authored content
  • Sling Models expose the data as JSON
  • React maps the JSON :type to a component and renders it

Step 1: Create a Dialog

Start by creating the AEM dialog for your Text component. This is the author-facing form that captures content.

Create a new component folder under:

/apps/your-project/components/text

Add a cq:dialog node and define the dialog fields.

Example dialog XML:

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
          xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
          xmlns:granite="http://www.adobe.com/jcr/granite/1.0"
          xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          jcr:primaryType="nt:unstructured"
          jcr:title="Text"
          sling:resourceType="cq/gui/components/authoring/dialog"
          extraClientlibs="[richtext.clientlibs,gpoc.rte.dialogevent]"
          helpPath="https://www.adobe.com/go/aem_cmp_text_v2"
          trackingFeature="core-components:text:v2">
    <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="Properties"
                                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 granite:class="cq-RichText-FixedColumn-column"
                                            jcr:primaryType="nt:unstructured"
                                            sling:resourceType="granite/ui/components/coral/foundation/container">
                                        <items jcr:primaryType="nt:unstructured">
                                            <text jcr:primaryType="nt:unstructured"
                                                  sling:resourceType="cq/gui/components/authoring/dialog/richtext"
                                                  fieldDescription="Please enter text"
                                                  name="./text"
                                                  useFixedInlineToolbar="{Boolean}true">
                                                <uiSettings jcr:primaryType="nt:unstructured">
                                                    <cui jcr:primaryType="nt:unstructured">
                                                        <inline jcr:primaryType="nt:unstructured"
                                                                toolbar="[format#bold,format#italic,format#underline,#justify,#lists,#styles,links#modifylink,links#unlink,#paraformat,findreplace#find,findreplace#replace,spellcheck#checktext,misctools#specialchars,links#anchor,subsuperscript#subscript,subsuperscript#superscript,undo#redo,undo#undo,edit#cut,edit#copy,edit#paste,edit#paste-default,edit#paste-plaintext,edit#paste-wordhtml,misctools#sourceedit,eaem-aem-fonts#applyFont]">
                                                            <popovers jcr:primaryType="nt:unstructured">
                                                                <justify jcr:primaryType="nt:unstructured"
                                                                         items="[justify#justifyleft,justify#justifycenter,justify#justifyright]"
                                                                         ref="justify"/>
                                                                <lists jcr:primaryType="nt:unstructured"
                                                                       items="[lists#unordered,lists#ordered,lists#outdent,lists#indent]"
                                                                       ref="lists"/>
                                                                <paraformat jcr:primaryType="nt:unstructured"
                                                                            items="paraformat:getFormats:paraformat-pulldown"
                                                                            ref="paraformat"/>
                                                                <styles jcr:primaryType="nt:unstructured"
                                                                        items="styles:getStyles:styles-pulldown"
                                                                        ref="styles"/>
                                                            </popovers>
                                                        </inline>
                                                    </cui>
                                                </uiSettings>
                                            </text>
                                            <shortEnabled granite:class="cq-dialog-checkbox-showhide"
                                                          jcr:primaryType="nt:unstructured"
                                                          sling:resourceType="granite/ui/components/coral/foundation/form/checkbox"
                                                          name="./shortEnabled"
                                                          text="Shortening enabled?"
                                                          uncheckedValue="{Boolean}false"
                                                          value="{Boolean}true">
                                                <granite:data jcr:primaryType="nt:unstructured"
                                                              cq-dialog-checkbox-showhide-target=".togglefield"/>
                                            </shortEnabled>
                                            <container granite:class="hide togglefield"
                                                       jcr:primaryType="nt:unstructured"
                                                       sling:resourceType="granite/ui/components/coral/foundation/container">
                                                <items jcr:primaryType="nt:unstructured">
                                                    <numberOfLines jcr:primaryType="nt:unstructured"
                                                                   sling:resourceType="granite/ui/components/coral/foundation/form/numberfield"
                                                                   fieldDescription="Number of lines to be presented when collapsed."
                                                                   fieldLabel="Number of lines"
                                                                   max="50"
                                                                   min="5"
                                                                   name="./numberOfLines"
                                                                   required="{Boolean}false"/>
                                                    <moreLink jcr:primaryType="nt:unstructured"
                                                              sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                              fieldDescription="Read more link text"
                                                              fieldLabel="Read More Text"
                                                              maxLength="{Long}50"
                                                              name="./moreLink"
                                                              required="{Boolean}false"/>
                                                    <lessLink jcr:primaryType="nt:unstructured"
                                                              sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                              fieldDescription="Read less link text"
                                                              fieldLabel="Read Less Text"
                                                              maxLength="{Long}50"
                                                              name="./lessLink"
                                                              required="{Boolean}false"/>
                                                </items>
                                                <granite:data jcr:primaryType="nt:unstructured"
                                                              showhidetargetvalue="true"/>
                                            </container>
                                            <id jcr:primaryType="nt:unstructured"
                                                sling:hideResource="{Boolean}true"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                name="./id"/>
                                        </items>
                                    </column>
                                </items>
                            </columns>
                        </items>
                    </properties>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

This dialog allows authors to enter text, enable shortening, and configure read-more options.

Step 2: Write the Sling Model

The Sling Model exports authored values to JSON for React.

Create a Java model under your core bundle, for example:

/apps/your-project/core/model/text

Example interface:

@ConsumerType
public interface TextModel extends Text {
    String getShortEnabled();
    String getNumberOfLines();
    String getMoreLink();
    String getLessLink();
    String getClassName();
}

Example implementation:

@Model(
    adaptables = { SlingHttpServletRequest.class },
    adapters = { TextModel.class, ComponentExporter.class },
    resourceType = { TextModelImpl.RESOURCE_TYPE },
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
@Exporter(name = ExporterConstants.SLING_MODEL_EXPORTER_NAME,
          extensions = ExporterConstants.SLING_MODEL_EXTENSION)
public class TextModelImpl implements TextModel {

    public static final String RESOURCE_TYPE = "yourproject/components/text/v1/text";

    @Self
    @Via(type = ResourceSuperType.class)
    @Delegate(types = Text.class)
    private Text text;

    @ValueMapValue
    private String shortEnabled;

    @ValueMapValue
    private String numberOfLines;

    @ValueMapValue
    private String moreLink;

    @ValueMapValue
    private String lessLink;

    @Override
    public String getShortEnabled() {
        return shortEnabled;
    }

    @Override
    public String getNumberOfLines() {
        return numberOfLines;
    }

    @Override
    public String getMoreLink() {
        return moreLink;
    }

    @Override
    public String getLessLink() {
        return lessLink;
    }

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

Important: @Exporter and getExportedType()

When using AEM SPA React components:

  • @Exporter converts the Sling Model into JSON.
  • extensions = ExporterConstants.SLING_MODEL_EXTENSION creates .model.json output.
  • getExportedType() sets the JSON :type value.

React uses that :type to decide which component should render the data.

Step 3: Generate the JSON

After deploying the Sling Model, open the page model JSON to verify the output:

http://localhost:4502/content/your-site/page.model.json

Look for your Text component’s JSON block. It should include:

  • authored values such as text, shortEnabled, numberOfLines, moreLink, lessLink
  • the exported :type field

Example JSON snippet:

":items": {
  "text_367608878": {
    "text": "<p>This is text component</p>\r\n",
    "shortEnabled": "true",
    "numberOfLines": "6",
    "moreLink": "read more",
    "lessLink": "read less",
    ":type": "yourproject/components/text/v1/text"
  }
},
":itemsOrder": ["text_367608878"],
":type": "wcm/foundation/components/responsivegrid"

Step 4: Map the Component in React

Next, connect the AEM component type to your React component in ui.frontend.

Create the React component, for example:

ui.frontend/apps/global/src/components/atoms/Text/Text.tsx

Example React code:

import React, { useState, useEffect } from 'react';

export const TextEditConfig = {
  emptyLabel: 'Text',
  isEmpty: (props: any) => !props?.text?.trim(),
};

interface TextProps {
  text?: string;
  shortEnabled?: string;
  numberOfLines?: string;
  moreLink?: string;
  lessLink?: string;
}

const Text: React.FC<TextProps> = ({ text, shortEnabled, numberOfLines, moreLink, lessLink }) => {
  const [isMoreVisible, setIsMoreVisible] = useState(true);

  useEffect(() => {
    if (shortEnabled === 'true') {
      document.querySelectorAll('p').forEach((paraTag) => {
        const el = paraTag as HTMLElement;
        el.style.overflow = 'hidden';
        el.style.display = '-webkit-box';
        el.style.webkitBoxOrient = 'vertical';
        el.style.webkitLineClamp = numberOfLines || '';
      });
    }
  }, [numberOfLines, shortEnabled]);

  const toggleReadMore = () => {
    document.querySelectorAll('p').forEach((paraTag) => {
      const el = paraTag as HTMLElement;
      if (isMoreVisible) {
        el.style.overflow = '';
        el.style.display = '';
        el.style.webkitBoxOrient = '';
        el.style.webkitLineClamp = '';
      } else {
        el.style.overflow = 'hidden';
        el.style.display = '-webkit-box';
        el.style.webkitBoxOrient = 'vertical';
        el.style.webkitLineClamp = numberOfLines || '';
      }
    });

    setIsMoreVisible(!isMoreVisible);
  };

  if (!text) return null;

  return (
    <section className="cmp-text">
      <div className="a-short" dangerouslySetInnerHTML={{ __html: text }} />
      {shortEnabled === 'true' && (
        <button type="button" onClick={toggleReadMore}>
          {isMoreVisible ? moreLink : lessLink}
        </button>
      )}
    </section>
  );
};

export default Text;

Then map the React component to the AEM resource type:

import Text, { TextEditConfig } from './atoms/Text/Text';
import { MapTo } from '@adobe/aem-react-editable-components';
import { mappingComponent } from './mapping-component';

MapTo('yourproject/components/text/v1/text')(
  mappingComponent(TextEditConfig, Text)
);

This binding tells React to render your Text component whenever AEM sends JSON with :type equal to yourproject/components/text/v1/text.

Step 5: Deploy and Author

Deploy the backend and frontend code so AEM can recognize the new component.

If you use local AEM packages:

mvn clean install -PautoInstallSinglePackage

If your frontend app is separate, build it too:

npm run build

Then author the component in AEM and confirm it renders in the SPA Editor.

Summary

Building an AEM React component requires three connected pieces:

  • a dialog for author input
  • a Sling Model exporter for JSON
  • a React component mapping for :type

When these are wired together, your React component becomes authorable inside AEM and renders with live content from the CMS.

If you want, I can also create a companion guide showing the exact folder structure for ui.apps, core, and ui.frontend in an AEM SPA project.