AEM Headless CMS architecture

AEM Headless CMS: Architecture, GraphQL, and Implementation

Content Fragments, GraphQL delivery, Universal Editor, and the practical tradeoffs of going headless with Adobe Experience Manager.

How AEM Headless Architecture Works

Your content doesn't have to be tied to a single website. By keeping core content in Adobe Experience Manager (AEM) Content Fragments and exposing it headlessly, you get a reusable content backbone that can feed websites, apps, chats, and anything else that can talk to an API.

This guide covers the full picture: how the headless architecture fits together, how to model and query content with GraphQL, implementation steps for a real app, delivery API options, where Universal Editor fits, and the honest tradeoffs you should weigh before committing.

AEM's headless model separates content from presentation at the platform level:

The result is a single source of truth: authors maintain content once in AEM, and multiple channels consume it independently.

Content Fragment Models: defining your schema

Before authors can create fragments, architects define Content Fragment Models — the schema for each content type. A model specifies:

Keep models focused — one content type per model rather than a single mega-model with optional fields for every use case. Avoid excessive nesting of Content Fragment references; deep reference chains make queries more expensive and harder to cache effectively. Adobe recommends creating only as many models as genuinely needed, keeping structures simple and well-bounded.

Querying Content with GraphQL

For production delivery, AEM requires persisted queries: GraphQL queries saved in AEM and exposed as stable GET endpoints. Ad hoc POST requests sending a query body are intended for development and exploration only; they are not cached and are blocked on standard Dispatcher/CDN configurations.

Persisted query endpoints follow the pattern:

https://publish-pXXXX-eYYYY.adobeaemcloud.com/graphql/execute.json/<configuration>/<query-name>

Here, <configuration> is the name of your GraphQL configuration (typically the root path of your content tree), and <query-name> is the name you gave the query when you persisted it in AEM. Query variables are appended as URL-encoded parameters in the request URL.

Calling a persisted query from a Next.js app

The example below uses the App Router with fetch and Next.js cache control. If you are using the Pages Router, the same fetch call works inside getServerSideProps (SSR) or getStaticProps with revalidate (ISR) — name the routing model you are targeting so your caching strategy is explicit.

// App Router — server component or route handler
async function fetchArticles() {
  const url = `${process.env.AEM_PUBLISH}/graphql/execute.json/my-project/article-list`;

  const response = await fetch(url, {
    // Next.js cache option; adjust revalidation to match your content freshness needs
    next: { revalidate: 60 },
  });

  if (!response.ok) {
    throw new Error(`AEM GraphQL request failed: ${response.status}`);
  }

  return response.json();
}

article-list is the name of a persisted query authored and published in AEM. The endpoint caches well at the CDN because the URL is stable — but actual caching behaviour depends on how your AEM environment and CDN are configured; a stable URL is a necessary condition, not a sufficient one.

Example persisted query: fetch a list of articles

Author this in the AEM GraphQL Query Editor (see the implementation steps section) and persist it as my-project/article-list:

{
  articleList {
    items {
      _path
      title
      summary
      publishDate
      author {
        name
        role
      }
      heroImage {
        ... on ImageRef {
          _path
          mimeType
          width
          height
        }
      }
    }
  }
}

For large result sets, apply pagination and filtering within the query rather than fetching all fragments and filtering on the client. AEM's GraphQL layer evaluates filters after loading all fragments that share a model into memory, so an unfiltered list query can be expensive even if only a handful of results are returned to the client.

Example persisted query: fetch a single article by path

{
  articleByPath(_path: "/content/dam/my-site/articles/my-article") {
    item {
      _path
      title
      body {
        html
        plaintext
      }
      tags
    }
  }
}

Implementation: step by step

The following sequence covers the author → preview → publish flow. Each step happens in a specific AEM environment — keep these distinct:

  1. Define Content Fragment Models on AEM Author via Tools → Assets → Content Fragment Models. One model per logical content type. Keep models simple; excessive nesting of references makes queries harder to maintain and cache.
  2. Create Content Fragments on AEM Author in the Assets or Sites console.
  3. Preview and publish Content Fragments. Fragments must be published to AEM Publish (or Preview for staging) before they appear via the GraphQL API. Publishing is an explicit author action; saving a fragment does not make it available to the delivery endpoint.
  4. Configure and publish a GraphQL endpoint on AEM Author via Tools → Assets → GraphQL Endpoints. Endpoints must also be published — unpublished endpoints return 404 from Publish.
  5. Author and test persisted queries using the GraphQL Query Editor at Tools → General → GraphQL Query Editor (or /aem/graphiql.html on Author). Configure your endpoint in the IDE before writing queries — the IDE requires a published endpoint to execute against. Save queries as persisted queries once you are satisfied.
  6. Publish persisted queries from the GraphQL Query Editor or via the configuration replication. Like fragments and endpoints, persisted queries must be published to be available on Publish.
  7. Call persisted query endpoints from your front end using GET requests (see the GraphQL section above). Your application should always call AEM Publish (or AEM Preview for staging); never call Author from a public-facing app.
  8. Instrument for Universal Editor (optional) — add data-aue-* attributes to your rendered HTML so authors can edit content in-context. See the section below.

Delivery API options: GraphQL vs OpenAPI

AEM as a Cloud Service exposes three distinct structured-content APIs. They serve different purposes and are not interchangeable:

API
Layer
Best for
Notes
GraphQL persisted queries
Publish (delivery)
Front-end apps fetching structured content
Stable GET URLs; caching depends on environment configuration. Recommended for most delivery use cases.
Content Fragment Delivery OpenAPI
Publish (delivery)
Delivery use cases that benefit from CDN integration and OpenAPI tooling
Optimised for delivery. May need to be enabled for your AEM Cloud environment via an Adobe Support request.
CF / CFM Management OpenAPI
Author (management)
Tooling, bulk CRUD operations, CI/CD pipelines against Author
Not for live delivery; operates on Author and requires author credentials.
GraphQL ad-hoc queries (POST)
Development only
Query exploration in GraphiQL IDE
Not cached; blocked by default on Dispatcher for Publish. Use the GraphQL Query Editor on Author only.

Cross-Channel Reuse and Governance

Keeping content in Content Fragments has concrete operational benefits beyond API delivery:

Universal Editor: in-context editing for headless apps

Headless setups traditionally had a trade-off: great developer flexibility, weaker authoring experience. AEM's Universal Editor closes that gap by providing in-context editing for headless and hybrid apps.

When a site or app is instrumented for Universal Editor:

<!-- Connection meta tag: note the urn:adobe:aue:system: prefix -->
<meta
  name="urn:adobe:aue:system:aemconnection"
  content="aem:https://author-pXXXX-eYYYY.adobeaemcloud.com"
/>
<script src="https://universal-editor-service.adobe.io/cors.js" async></script>
export function ArticleTeaser({ fragmentPath, title }) {
  return (
    <h2
      data-aue-resource={`urn:aemconnection:${fragmentPath}`}
      data-aue-type="content-fragment"
      data-aue-prop="title"
    >
      {title}
    </h2>
  );
}

For a complete walkthrough with a working demo, see Integrating AEM Universal Editor with a Next.js App.

Getting Started with the Universal Editor in AEM · Attributes and Types

Tradeoffs: when headless is and isn't the right call

When headless with Content Fragments is the right choice

When to consider an alternative

For the broader architecture context see Experience Delivery Foundation Overview.