Headless AEM with GraphQL: A Hands-On Walkthrough
Series: Advanced AEM & Career — Part 1

In the Core Development series we created Content Fragment Models and touched on GraphQL. This post builds on that foundation and goes hands-on: enabling the GraphQL endpoint, writing queries, filtering and paging, saving persisted queries, and calling them from a front-end application.
What "Headless" Means in AEM
In a traditional (headful) AEM site, AEM stores the content and renders the HTML. In a headless setup, AEM stores and manages the content, and exposes it through an API. A separate front end, such as a React app, a mobile app or a kiosk, fetches that content and renders it however it likes.
AEM offers several ways to deliver content headlessly:
| Option | What you get | Best for |
|---|---|---|
| Content Fragments + GraphQL API | Structured content, queried precisely | Apps and sites that need typed, reusable content |
Sling Model Exporter (.model.json) |
JSON of a page or component | Single-page apps that mirror page structure |
| Experience Fragments | Reusable blocks of experience (layout plus content) | Sharing designed blocks across channels |
This post focuses on the first, which is the strategic direction for headless content in AEM.
Prerequisites
You'll need:
- AEM as a Cloud Service (or a local SDK) with Content Fragment Models and a few fragments created, such as the
ArticleandAuthormodels from the Core Development series - The fragments published, if you want to query them from Publish
- A configuration that has Content Fragment Models and GraphQL persisted queries enabled
Step 1: Enable GraphQL for Your Configuration
GraphQL endpoints in AEM are tied to configurations:
- Go to Tools → General → Configuration Browser
- Open your configuration (the same one that enables Content Fragment Models)
- Make sure Content Fragment Models and GraphQL Persisted Queries are enabled, then save
Queries can use the global endpoint or a site-specific endpoint that exposes only the models in that configuration. Site-specific endpoints keep different projects' schemas separate, and they are the better choice for real projects.
AEM generates the GraphQL schema automatically from your models, so the field names you chose in the model become part of the API. This is why field naming (covered in Part 6 of the Core Development series) matters so much.
Step 2: Explore with the GraphiQL IDE
AEM includes GraphiQL, an in-browser editor for writing and testing queries. You'll find it under Tools → General → GraphQL Query Editor. Select your endpoint, and you get:
- An editor with auto-complete that knows your schema
- A Docs panel showing every type and field available
- A button to run the query and view the JSON response
Develop all your queries here first, and save them as persisted queries from the same screen when they work.
Step 3: Write Queries
For each model, AEM generates operations such as:
| Operation | Returns |
|---|---|
articleList |
A list of all articles |
articleByPath |
One article, looked up by its repository path |
articlePaginated |
A page of articles, using cursor-based paging |
A Basic List Query
{
articleList {
items {
_path
title
summary
publishDate
}
}
}
_path is a built-in field with the fragment's repository path. Other built-in fields include _variation and _metadata.
Query One Item by Path
{
articleByPath(_path: "/content/dam/mysite/articles/getting-started") {
item {
title
body {
html
}
author {
name
bio {
plaintext
}
}
}
}
}
Two things to notice:
- Rich text fields let you choose the output format (
html,plaintext,jsonormarkdown), so each client gets what it needs - Fragment references (like
author) are resolved inline, so one request returns the article and its author
Images and Other Content References
For references to DAM assets, ask for the type-specific fields:
{
articleList {
items {
title
heroImage {
... on ImageRef {
_path
_publishUrl
width
height
}
}
}
}
}
_publishUrl gives you an absolute URL on the Publish tier, which is what a public front end needs.
Filtering
{
articleList(
filter: {
category: {
_expressions: [{ value: "tech", _operator: EQUALS }]
}
}
) {
items {
title
category
}
}
}
Filters support several operators (equals, contains, comparisons and more), and you can combine conditions with _logOp (AND/OR). Check the GraphiQL Docs panel to see exactly what your schema supports.
Sorting and Limiting
{
articleList(sort: "publishDate DESC", limit: 10) {
items {
title
publishDate
}
}
}
Cursor-Based Paging
For large result sets, use the paginated operation:
{
articlePaginated(first: 10, after: "<cursor-from-previous-page>") {
edges {
cursor
node {
title
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
Request the first page without after, then pass the previous page's endCursor as after to get the next. Note that AEM supports forward paging (first/after), and backward paging (last/before) isn't supported.
Content Fragment Variations
If authors create variations of a fragment (for example a short version for mobile), request one with the _variation argument on the by-path query, or select it as part of a list query. Without it you get the master variation.
Step 4: Persist Your Queries
Sending arbitrary GraphQL via POST works in development, but it isn't what you should use in production. Use persisted queries:
- You save the query on the server under a name
- Clients call it with a plain GET request
- Because it's a GET with a stable URL, it can be cached at the Dispatcher and CDN
Adobe strongly recommends persisted queries, because they let the CDN do the work and spare AEM from executing the same query repeatedly.
Saving a Query
The easiest route is the GraphiQL IDE: write the query, then use Save As to give it a name. You can also create one through the API by sending a PUT to /graphql/persist.json/<config>/<query-name>:
curl -X PUT \
"https://<author-host>/graphql/persist.json/mysite/latest-articles" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "query": "{ articleList(sort: \"publishDate DESC\", limit: 10) { items { _path title summary } } }" }'
Calling a Persisted Query
GET https://<publish-host>/graphql/execute.json/mysite/latest-articles
Persisted queries can take variables, passed in the URL separated by semicolons:
GET .../graphql/execute.json/mysite/articles-by-category;category=tech
(In a real URL, the ; and = are usually percent-encoded.)
Controlling Cache Headers
Each persisted query can have its own Cache-Control settings, so a rarely changing list can cache for longer than a fast-moving feed. You can set them in GraphiQL, or wrap the query when saving it through the API:
{
"query": "{ articleList { items { _path title } } }",
"cache-control": { "max-age": 300 }
}
Remember that AEM's Dispatcher is the first-level cache, followed by the CDN, so the same invalidation and TTL thinking from the Architecture series applies here. Check that your Dispatcher configuration allows these /graphql/execute.json paths to be cached, and that your TTLs match how fresh the content must be.
Step 5: Call It from a Front End
A plain fetch is enough:
async function getLatestArticles() {
const response = await fetch(
'https://publish.example.com/graphql/execute.json/mysite/latest-articles'
);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const { data } = await response.json();
return data.articleList.items;
}
Adobe also publishes an AEM Headless client library for JavaScript that wraps persisted query calls, handles paths and variables, and supports both browser and Node.js use. It is worth using in larger projects, so check Adobe's current documentation for its package name and usage.
Two practical points:
- CORS: if your front end runs on a different domain from AEM Publish, configure an AEM CORS policy that allows that origin. Without it, browsers block the request
- Author vs Publish: published content is served from Publish, which can be accessed anonymously. To preview unpublished content, query Author, which requires authentication, and keep that restricted to your preview environment
Design Tips
- Model for reuse and clear types. Fragments named for content (Article, Author, Product) work across channels. Fragments shaped like a single page do not
- Use persisted queries in production, and keep ad hoc queries for development
- Ask only for the fields you need. Smaller responses are faster and cache better
- Watch nesting depth. Deeply nested references multiply the work AEM does per query
- Page large lists instead of requesting everything
- Use filtering and sorting in the query, not in client-side JavaScript after downloading everything
- Version your queries. Treat a persisted query like a public API, and create a new name rather than changing the shape of one that clients depend on
- Plan governance for models. A model change can break every client that queries those fields
Troubleshooting
- Empty
items: the fragments may not be published (when querying Publish), or the query filters out everything. Run the same query on Author in GraphiQL to compare - Field not in the schema: the model may not be published or the endpoint's configuration doesn't include it. Check the model's status and the endpoint you selected
- 404 on
execute.json: the persisted query isn't published or the path (config and name) is wrong - CORS errors in the browser: add or fix the CORS policy for your front-end origin
- Stale results: check Cache-Control settings on the query, then Dispatcher and CDN TTLs
- Slow queries: reduce nesting, add paging and filtering, and check that the query is persisted so it can be cached
What's Next
Headless GraphQL is one way to move beyond classic AEM Sites rendering. The next post looks at another direction that Adobe is investing in heavily: Edge Delivery Services, and what it means for developers who built their careers on traditional AEM.
Next in this series: [AEM + Edge Delivery Services]
About the author
- AEM Technical Lead experienced with building and architecting solutions on AEM Sites and Assets. Sharing practical tutorials, real-world implementation lessons, and best practices to help fellow developers and leads navigate AEM.
Comments
Join the discussionAre you building headless with AEM yet, or still evaluating it? Which front end are you using, and how are you handling caching for persisted queries?
Advanced AEM & Career
"Headless AEM, Edge Delivery Services, and career growth — for developers ready to go beyond the basics."





