Skip to main content

AEM SPA Editor

The AEM SPA Editor enables authors to visually edit Single Page Applications (React or Angular) directly in the AEM author environment -- drag-and-drop, inline editing, layout mode, and all the authoring features they know from traditional AEM pages. The SPA renders from a JSON model served by AEM, and the SPA Editor SDK bridges the gap between the SPA framework and AEM's authoring layer.


How It Works

Architecture overview

Key principles:

  1. AEM serves a JSON model of the page (via Sling Model Exporters)
  2. The SPA reads the JSON and renders React/Angular components
  3. The SPA Editor SDK adds authoring overlays (selection, drag targets, placeholders)
  4. Edits in the author UI are written to the JCR, the JSON model updates, and the SPA re-renders
  5. On publish, the SPA renders from the same JSON without any editor overlays

JSON model structure

The page model is a nested JSON tree. Each component maps to a node:

{
":type": "myproject/components/page",
":path": "/content/myproject/en/home",
":children": {
"root": {
":type": "wcm/foundation/components/responsivegrid",
":items": {
"hero": {
":type": "myproject/components/hero",
"title": "Welcome",
"backgroundImage": "/content/dam/myproject/hero.jpg"
},
"cards": {
":type": "myproject/components/cards",
":items": { ... }
}
},
":itemsOrder": ["hero", "cards"]
}
}
}

Project Setup

AEM Project Archetype

The AEM Project Archetype generates a complete SPA project when you select the frontend module:

mvn -B archetype:generate \
-DarchetypeGroupId=com.adobe.aem \
-DarchetypeArtifactId=aem-project-archetype \
-DarchetypeVersion=49 \
-DappTitle="My SPA Project" \
-DappId="myspa" \
-DgroupId="com.myproject" \
-DfrontendModule=react

This generates:

myspa/
├── core/ # Java backend (Sling Models, Exporters)
├── ui.apps/ # AEM components, templates, clientlibs
├── ui.content/ # Sample content
├── ui.config/ # OSGi configs
├── ui.frontend/ # React SPA (the frontend module)
│ ├── src/
│ │ ├── components/ # React components mapped to AEM components
│ │ ├── App.js
│ │ └── index.js
│ ├── package.json
│ └── webpack.config.js
└── pom.xml

Key npm packages

PackagePurpose
@adobe/aem-react-editable-componentsReact components with editor support (MapTo, EditableComponent)
@adobe/aem-spa-page-model-managerFetches and caches the page JSON model
@adobe/aem-spa-component-mappingRegistry that maps AEM resource types to React components
@adobe/aem-core-components-react-baseReact wrappers for AEM Core Components
@adobe/aem-core-components-react-spaSPA-specific Core Component wrappers
npm install @adobe/aem-react-editable-components \
@adobe/aem-spa-page-model-manager \
@adobe/aem-spa-component-mapping

Component Mapping with MapTo

The central concept: MapTo() connects a React component to an AEM resource type. When the JSON model contains a node with :type = "myproject/components/hero", the SPA renders the mapped React component.

Basic mapping

ui.frontend/src/components/Hero/Hero.js
import React from 'react';
import { MapTo } from '@adobe/aem-react-editable-components';

// Define edit config (controls placeholder behaviour in author mode)
const HeroEditConfig = {
emptyLabel: 'Hero',
isEmpty: function(props) {
return !props || !props.title;
}
};

const Hero = ({ title, backgroundImage, ctaText, ctaLink }) => (
<section className="hero" style={{
backgroundImage: `url(${backgroundImage})`
}}>
<div className="hero-content">
<h1>{title}</h1>
{ctaText && (
<a href={ctaLink} className="hero-cta">{ctaText}</a>
)}
</div>
</section>
);

export default MapTo('myproject/components/hero')(Hero, HeroEditConfig);

How MapTo works

  1. The JSON model contains a :type field for each component
  2. MapTo('myproject/components/hero')(HeroComponent, editConfig) registers the mapping
  3. When the page model manager encounters that resource type, it renders the mapped component
  4. The editConfig tells the editor how to handle empty states and placeholders

Edit config options

PropertyTypeDescription
emptyLabelstringLabel shown in the placeholder when the component is empty
isEmpty(props)functionReturns true if the component should show the placeholder
resourceTypestringOverride the resource type (rarely needed)

Container Components

Container components (like the Responsive Grid) hold child components. They receive :items and :itemsOrder in the JSON model.

Mapping a container

ui.frontend/src/components/Container/Container.js
import React from 'react';
import { MapTo, withComponentMappingContext } from '@adobe/aem-react-editable-components';
import { ResponsiveGrid } from '@adobe/aem-react-editable-components';

// Use the built-in ResponsiveGrid for layout container
MapTo('wcm/foundation/components/responsivegrid')(ResponsiveGrid);

Custom container

For a custom container (e.g., a tabbed section), see the Extending the Responsive Grid page for a detailed implementation with ContainerExporter and @Delegate.


Page Component

The page component is the root of the SPA. It renders the page-level model and all child containers:

ui.frontend/src/App.js
import React from 'react';
import { Page, withModel } from '@adobe/aem-react-editable-components';
import { ModelManager } from '@adobe/aem-spa-page-model-manager';

// Import all component mappings so they register
import './components/Hero/Hero';
import './components/Cards/Cards';
import './components/Text/Text';
// ...

// Initialise the model manager
ModelManager.initialize().then(() => {
// Ready to render
});

function App() {
return <Page />;
}

// withModel HOC fetches the page model and passes it as props
export default withModel(App);

Entry point

ui.frontend/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { ModelManager } from '@adobe/aem-spa-page-model-manager';
import App from './App';

ModelManager.initialize().then((pageModel) => {
ReactDOM.render(
<App
cqChildren={pageModel[':children']}
cqItems={pageModel[':items']}
cqItemsOrder={pageModel[':itemsOrder']}
cqPath={pageModel[':path']}
/>,
document.getElementById('spa-root')
);
});

Backend: Sling Model Exporters

Every AEM component that appears in the SPA needs a Sling Model Exporter that produces the JSON representation:

core/.../models/HeroModel.java
import com.adobe.cq.export.json.ComponentExporter;
import com.adobe.cq.export.json.ExporterConstants;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.models.annotations.Exporter;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;

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

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

@ValueMapValue(optional = true)
private String title;

@ValueMapValue(optional = true)
private String backgroundImage;

@ValueMapValue(optional = true)
private String ctaText;

@ValueMapValue(optional = true)
private String ctaLink;

public String getTitle() { return title; }
public String getBackgroundImage() { return backgroundImage; }
public String getCtaText() { return ctaText; }
public String getCtaLink() { return ctaLink; }

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

The @Exporter annotation and ComponentExporter interface ensure the model is included in the page JSON model. The getExportedType() method maps back to the :type field.


SPA Routing and Navigation

SPAs handle client-side routing. The SPA Editor SDK integrates with React Router:

ui.frontend/src/App.js
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { ModelManager, ModelClient } from '@adobe/aem-spa-page-model-manager';

// Configure the model manager to fetch models for new routes
const modelClient = new ModelClient();

function App() {
return (
<Router>
<Route
path="/:pagePath*"
render={(routeProps) => (
<Page
cqPath={routeProps.match.url}
{...routeProps}
/>
)}
/>
</Router>
);
}

When a user navigates to a new page:

  1. React Router handles the URL change
  2. ModelManager fetches the JSON model for the new page (if not cached)
  3. The Page component re-renders with the new model
  4. In author mode, the editor overlays update automatically

Remote SPA

A Remote SPA is a SPA hosted outside AEM (e.g., on Vercel, Netlify, or a custom CDN) that can still be edited in the AEM SPA Editor:

Setup

  1. Host your SPA on an external server
  2. Configure AEM to allow the remote origin (CORS, CSP headers)
  3. The SPA fetches the page model from AEM's JSON endpoint
  4. The SPA Editor loads the remote SPA in an iframe for authoring
// Configure ModelManager for remote SPA
ModelManager.initialize({
modelClient: new ModelClient('https://author-p123-e456.adobeaemcloud.com')
});

When to use Remote SPA

ScenarioRemote SPAEmbedded SPA
SPA hosted on external CDNYesNo
SPA needs independent deploymentYesNo
Tight AEM integration neededNoYes
Multiple teams own different partsYesNo
Maximum authoring fidelityNoYes (same origin)

SPA vs Headless vs Edge Delivery

AspectSPA EditorHeadless (GraphQL)Edge Delivery
AuthoringVisual, in-contextNo visual editing (external frontend)Universal Editor or document-based
FrontendReact / Angular (AEM-hosted or remote)Any framework (decoupled)Vanilla JS + CSS
Content modelPage model (JSON tree)Content Fragments (structured)Content Bus (tables to HTML)
RenderingClient-side (SPA)Client-side or SSR (custom)Edge-side (CDN)
PerformanceGood (but SPA overhead)Depends on implementationExcellent (100 Lighthouse)
ComplexityHigh (SDK, mapping, exporters)Medium (GraphQL queries)Low (HTML, CSS, JS)
Best forComplex interactive appsMulti-channel content deliveryMarketing sites, docs

Authoring Experience

Edit mode

Authors see the SPA with blue overlays on each editable component. They can:

  • Click to select a component
  • Use the component toolbar (edit, configure, delete, move)
  • Drag components from the side panel
  • Inline-edit text components

Layout mode

Authors can resize components within the responsive grid, just like traditional AEM pages.

Preview mode

Renders the SPA without editor overlays -- exactly as visitors would see it.

Placeholder handling

When a component is empty (no content authored yet), the SPA Editor SDK shows a placeholder based on the emptyLabel from the edit config. This guides authors to add content.

const TextEditConfig = {
emptyLabel: 'Text -- click to edit',
isEmpty: (props) => !props || !props.text || props.text.trim().length === 0
};

Common Patterns

Conditional authoring scaffolding

Show additional markup only in edit mode to help authors:

import { AuthoringUtils } from '@adobe/aem-spa-page-model-manager';

const MyComponent = (props) => {
const isInEditor = AuthoringUtils.isInEditor();

return (
<div className="my-component">
{isInEditor && <div className="author-hint">Drag items here</div>}
{/* Component content */}
</div>
);
};

Mapping Core Components

Reuse AEM Core Component React wrappers:

import { TitleV2IsEmptyFn } from '@adobe/aem-core-components-react-base';
import { MapTo } from '@adobe/aem-react-editable-components';

// Map the AEM Core Title component to its React wrapper
const Title = ({ text, type }) => {
const HeadingTag = type || 'h2';
return <HeadingTag>{text}</HeadingTag>;
};

MapTo('myproject/components/title')(Title, {
emptyLabel: 'Title',
isEmpty: TitleV2IsEmptyFn
});

Best Practices

Map every editable component

Every AEM component that authors should be able to edit needs a MapTo registration. Missing mappings result in blank spots in the editor.

Keep edit configs accurate

The isEmpty() function determines when the placeholder is shown. Make sure it correctly identifies truly empty components to avoid confusing authors.

Test in both author and publish modes

The SPA behaves differently in author mode (with editor overlays) vs publish mode (clean rendering). Test both paths.

Avoid direct DOM manipulation

The SPA Editor SDK manages component lifecycles. Direct DOM manipulation (e.g., jQuery) can conflict with the editor overlays and cause rendering glitches.

Use Sling Model Exporters for all data

Don't fetch data client-side via custom APIs for content that should be editable. All editable content must flow through the JSON model.


Common Pitfalls

PitfallSolution
Component renders on publish but not in editorEnsure MapTo is imported and executed before the page renders
Empty placeholder never disappearsCheck the isEmpty() function -- it may not be checking the right props
Drag-and-drop doesn't workVerify the container component is correctly mapped (e.g., ResponsiveGrid)
JSON model is empty or missing componentsCheck that Sling Models implement ComponentExporter and have @Exporter
SPA loads but shows blank page in editorVerify the SPA page component in AEM has sling:resourceSuperType = core/wcm/components/page/v3/page
Client-side routing breaks the editorEnsure ModelManager.initialize() completes before rendering routes
Styles look different in author vs publishThe editor adds wrapper divs; scope your CSS to handle both cases
Build fails with aem-react-editable-components version mismatchPin all @adobe/aem-* packages to compatible versions

External Resources

See also