AEM Headless GraphQL
AEM provides a built-in GraphQL API that exposes Content Fragments as a headless data source. Frontend applications (SPAs, mobile apps, static sites) query Content Fragment data via GraphQL without needing any AEM rendering pipeline.
How It Works
AEM auto-generates a GraphQL schema from your Content Fragment Models. Every model becomes a GraphQL type, every field becomes a queryable property, and fragment references become nested types. No manual schema writing is needed.
| CF Model Field Type | GraphQL Type | Notes |
|---|---|---|
| Single-line text | String | |
| Multi-line text | Object with html, plaintext, markdown, json sub-fields | Choose the format via sub-selection |
| Number (Integer) | Int | |
| Number (Float) | Float | |
| Boolean | Boolean | |
| Date and Time | Calendar (ISO 8601 string) | |
| Enumeration | String (with defined values) | |
| Tags | [String] | Tag IDs |
| Content Reference | String (path) or typed reference | |
| Fragment Reference | Nested type | Becomes a queryable relationship |
| JSON Object | json scalar | Returned as raw JSON |
Setting Up GraphQL Endpoints
Creating an endpoint
- Navigate to Tools > General > GraphQL
- Click Create and configure:
- Name: e.g.,
myproject - Configuration: Select the site configuration that holds your CF Models (e.g.,
/conf/myproject)
- The endpoint is stored at the repository path and served at the request URL (
:is escaped to_in the URL):
Repository path: /content/cq:graphql/myproject/endpoint
Request URL: /content/_cq_graphql/myproject/endpoint.json
Global vs site-specific endpoints
| Endpoint | Path | Use case |
|---|---|---|
| Global | /content/_cq_graphql/global/endpoint.json | Access models from all configurations |
| Site-specific | /content/_cq_graphql/myproject/endpoint.json | Models from /conf/myproject plus /conf/global |
Use site-specific endpoints in production to limit the schema scope and improve security. The global endpoint exposes all models across all sites.
Querying with GraphQL
GraphiQL IDE
On the local AEMaaCS SDK, GraphiQL is available at:
http://localhost:4502/aem/graphiql.html
On AEMaaCS cloud, GraphiQL is available on the author instance of your environment:
https://author-pXXXXX-eXXXXX.adobeaemcloud.com/aem/graphiql.html
AEM 6.5 with the GraphiQL add-on used /content/graphiql.html instead; that path 404s on the AEMaaCS SDK.
GraphiQL provides:
- Schema explorer with auto-complete
- Query history
- Variables panel
- Response viewer with formatting
Basic queries
List query - fetch multiple fragments of a type:
{
articleList {
items {
_path
title
body {
html
}
publishDate
featured
category
}
}
}
Single fragment by path:
{
articleByPath(_path: "/content/dam/myproject/articles/getting-started") {
item {
title
body {
html
plaintext
}
publishDate
author {
name
bio {
plaintext
}
}
}
}
}
Multi-line text sub-fields
Multi-line text fields expose content in multiple formats:
{
articleList {
items {
body {
html # HTML-formatted content
plaintext # Plain text (tags stripped)
markdown # Markdown (if authored as Markdown)
json # Structured JSON representation
}
}
}
}
Fragment references (nested queries)
Fragment references are resolved automatically as nested types:
{
articleList {
items {
title
# Single fragment reference
author {
name
bio { html }
profileImage {
... on ImageRef {
_path
width
height
}
}
}
# Multi-valued fragment reference
relatedArticles {
title
_path
}
}
}
}
Content references and image references
Content references to DAM assets can be queried with metadata:
{
articleList {
items {
title
featuredImage {
... on ImageRef {
_path
_authorUrl
_publishUrl
mimeType
width
height
}
}
}
}
}
Filtering, Sorting, and Pagination
Filtering
Use the filter argument on list queries. Each field supports type-specific operators:
{
articleList(
filter: {
featured: { _expressions: [{ value: true }] }
category: { _expressions: [{ value: "technology" }] }
}
) {
items {
title
category
}
}
}
Filter operators
| Operator | Applies to | Example |
|---|---|---|
value | All types | Exact match |
_operator: EQUALS | String, ID, Boolean | Default; exact match |
_operator: EQUALS_NOT | String, ID | Not equal |
_operator: EQUAL / UNEQUAL | Int, Float | Equal / not equal |
_operator: CONTAINS | String | Substring match (case-sensitive) |
_operator: CONTAINS_NOT | String | Does not contain substring |
_operator: STARTS_WITH | ID (e.g. _path) | Prefix match |
_operator: LOWER | Int, Float | Less than |
_operator: LOWER_EQUAL | Int, Float | Less than or equal |
_operator: GREATER | Int, Float | Greater than |
_operator: GREATER_EQUAL | Int, Float | Greater than or equal |
_operator: AT | Date, Calendar, Time | Exact date match |
_operator: BEFORE | Date, Calendar, Time | Before date |
_operator: AFTER | Date, Calendar, Time | After date |
String filtering:
{
articleList(
filter: {
title: {
_expressions: [{
value: "AEM"
_operator: CONTAINS
}]
}
}
) {
items { title }
}
}
Date range filtering:
{
articleList(
filter: {
publishDate: {
_expressions: [
{ value: "2025-01-01T00:00:00Z", _operator: AFTER },
{ value: "2025-12-31T23:59:59Z", _operator: BEFORE }
]
}
}
) {
items { title publishDate }
}
}
Combining filters (AND logic - all conditions must match):
{
articleList(
filter: {
category: { _expressions: [{ value: "technology" }] }
featured: { _expressions: [{ value: true }] }
title: { _expressions: [{ value: "AEM", _operator: CONTAINS }] }
}
) {
items { title category featured }
}
}
Sorting
{
articleList(
sort: "publishDate DESC"
) {
items {
title
publishDate
}
}
}
Sort by multiple fields:
{
articleList(
sort: "category ASC, publishDate DESC"
) {
items { title category publishDate }
}
}
Pagination (offset-based)
{
articleList(
offset: 0
limit: 10
) {
items {
title
}
}
}
Cursor-based pagination (for large datasets):
{
articlePaginated(
first: 10
after: "eyJjdXJzb3IiOiIxMCJ9"
) {
edges {
node {
title
publishDate
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
Variations
Query a specific Content Fragment variation instead of the master:
{
articleList(variation: "summary") {
items {
title
body {
html
}
}
}
}
If a field has no value in the requested variation, it falls back to the master variation.
Persisted Queries
Persisted queries are pre-stored, named queries on the server. They are the recommended approach for production because they are:
- Cacheable by Dispatcher and CDN (served as GET requests with a stable URL)
- Protected from arbitrary query injection
- Faster (no query parsing on each request)
Creating a persisted query
# Local SDK / dev only - admin:admin is fine against the local author. In real environments
# authenticate the CI job that publishes persisted queries with a scoped service account.
# PUT a persisted query
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/json" \
-d '{ "query": "{ articleList(sort: \"publishDate DESC\", limit: 10) { items { _path title body { html } publishDate category } } }" }' \
"http://localhost:4502/graphql/persist.json/myproject/latest-articles"
Executing a persisted query
# GET request (cacheable)
curl "http://localhost:4502/graphql/execute.json/myproject/latest-articles"
# With variables (end with semicolon or encode delimiters)
curl "http://localhost:4502/graphql/execute.json/myproject/articles-by-category;category=technology;limit=5;"
Persisted queries with variables
# Create a parameterised persisted query
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/json" \
-d '{
"query": "query ArticlesByCategory($category: String!, $limit: Int = 10) { articleList(filter: { category: { _expressions: [{ value: $category }] } }, limit: $limit, sort: \"publishDate DESC\") { items { _path title publishDate category } } }",
"variables": {}
}' \
"http://localhost:4502/graphql/persist.json/myproject/articles-by-category"
# Execute with variables in the URL path (with trailing semicolon)
curl "http://localhost:4502/graphql/execute.json/myproject/articles-by-category;category=technology;limit=5;"
Persisted query URLs
| URL Pattern | Description |
|---|---|
/graphql/persist.json/{config}/{query-name} | Create (PUT) or update (POST) a persisted query |
/graphql/execute.json/{config}/{query-name} | Execute a persisted query (GET) |
/graphql/execute.json/{config}/{query-name};var1=val1;var2=val2 | Execute with variables; encode ;/= as %3B/%3D or end the URL with ; |
/graphql/list.json/{config} | List all persisted queries for a config |
Fetching from Frontend Applications
JavaScript / TypeScript
const AEM_HOST = process.env.NEXT_PUBLIC_AEM_HOST || 'https://publish-pXXXXX-eXXXXXX.adobeaemcloud.com';
/**
* Execute a persisted query against AEM GraphQL.
*/
export async function fetchPersistedQuery<T>(
queryPath: string,
variables?: Record<string, string | number>
): Promise<T> {
// Build URL with variables
let url = `${AEM_HOST}/graphql/execute.json/${queryPath}`;
if (variables) {
const params = Object.entries(variables)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join(';');
url += `;${params};`;
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Add auth token for Author queries (not needed for Publish)
// 'Authorization': 'Bearer <access-token>',
},
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
}
const json = await response.json();
if (json.errors) {
console.error('GraphQL errors:', json.errors);
throw new Error(json.errors.map((e: { message: string }) => e.message).join(', '));
}
return json.data;
}
interface Article {
_path: string;
title: string;
body: { html: string };
publishDate: string;
}
interface ArticleListResponse {
articleList: { items: Article[] };
}
// Fetch latest articles
const data = await fetchPersistedQuery<ArticleListResponse>(
'myproject/latest-articles'
);
const articles = data.articleList.items;
// Fetch by category with variables
const techArticles = await fetchPersistedQuery<ArticleListResponse>(
'myproject/articles-by-category',
{ category: 'technology', limit: 5 }
);
React hook example
import { useState, useEffect } from 'react';
import { fetchPersistedQuery } from '../lib/graphql';
export function useArticles(category?: string) {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const queryPath = category
? 'myproject/articles-by-category'
: 'myproject/latest-articles';
const variables = category ? { category } : undefined;
fetchPersistedQuery<ArticleListResponse>(queryPath, variables)
.then(data => setArticles(data.articleList.items))
.catch(setError)
.finally(() => setLoading(false));
}, [category]);
return { articles, loading, error };
}
Infrastructure Configuration
Dispatcher filters
Allow GraphQL requests through the Dispatcher:
# Allow GraphQL persisted queries (GET)
/0100 {
/type "allow"
/method "GET"
/url "/graphql/execute.json/*"
}
# Allow GraphQL endpoint (GET + POST + OPTIONS preflight)
/0101 {
/type "allow"
/method '(GET|POST|OPTIONS)'
/url "/content/_cq_graphql/*/endpoint.json"
}
Dispatcher client headers
When Dispatcher caching for persisted queries is enabled, the Origin and Access-Control-Request-*
headers must not be passed to AEM Publish, because Dispatcher does not cache the Vary header or
per-origin CORS headers. Instead, configure CORS at the Dispatcher level (Apache virtual host) for
Publish (see Adobe's Dispatcher Caching for Persisted Queries).
Pass only headers such as Authorization to Publish when needed:
$include "./default_clientheaders.any"
"Authorization"
Dispatcher caching for persisted queries
Persisted-query responses are served via GET and can be cached by Dispatcher. First enable Dispatcher
caching of persisted queries in global.vars:
# Cache persisted-query responses in the Dispatcher
Define CACHE_GRAPHQL_PERSISTED_QUERIES
Then allow only the persisted-query path in the cache rules - a broad *.json glob would also cache
JSON endpoints that must not be cached:
# Cache persisted query responses only
/0100 {
/glob "/graphql/execute.json/*"
/type "allow"
}
AEM sets Cache-Control and Surrogate-Control TTL headers for persisted queries by default
(Publish default: max-age 60, s-maxage 7200). Change them per query, via OSGi or via Cloud Manager
variables, not with Header set directives in the vhost.
CORS configuration
For Author (or Publish environments where Dispatcher caching is not enabled), configure CORS via OSGi to allow cross-origin GraphQL requests from your frontend domains:
{
"supportscredentials": false,
"supportedmethods": [
"GET",
"HEAD",
"POST",
"OPTIONS"
],
"alloworigin": [
"https://www.myproject.com",
"https://app.myproject.com"
],
"maxage:Integer": 1800,
"alloworiginregexp": [
"https://deploy-preview-.*\\.myproject\\.com",
"http://localhost:.*"
],
"allowedpaths": [
"/content/_cq_graphql/myproject/endpoint.json",
"/graphql/execute.json/.*"
],
"supportedheaders": [
"Origin",
"Accept",
"X-Requested-With",
"Content-Type",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
"Authorization"
]
}
Prefer explicit domain allowlists in alloworigin over wildcards. On AEMaaCS, use alloworiginregexp
for preview/staging URLs but always enumerate production domains explicitly.
Referrer filter
AEM's Referrer Filter is a singleton (not an OSGi configuration factory), meaning only one configuration is active at a time. This configuration replaces the default configuration and is only needed for POST requests (GET persisted queries require no Referrer Filter configuration):
{
"allow.empty": false,
"allow.hosts": [
"www.myproject.com",
"app.myproject.com"
],
"allow.hosts.regexp": [
"https://deploy-preview-.*\\.myproject\\.com:443",
"http://localhost:.*"
],
"filter.methods": [
"POST",
"PUT",
"DELETE"
],
"exclude.agents.regexp": []
}
Caching Strategy
| Layer | Cache mechanism | Invalidation |
|---|---|---|
| Browser | Cache-Control header | Time-based (TTL) |
| CDN | Surrogate-Control or Cache-Control | Tag-based purge, TTL, or deploy hook |
| Dispatcher | File-based cache (.stat invalidation) | Flush agent on AEM 6.5; Adobe-managed invalidation on AEMaaCS |
| AEM Publish | In-memory query cache | Automatic on content change |
Cache invalidation on publish
On AEM 6.5, configure a Dispatcher flush agent so Content Fragment publication invalidates cached GraphQL responses:
/0001 {
/glob "*.json"
/type "allow"
}
On AEM as a Cloud Service, persisted-query responses expire by TTL (Publish default s-maxage
7200 s; tune it per query, via OSGi or via Cloud Manager variables). The Adobe-managed CDN is not
flushed on publication; do not configure custom flush agents on the cloud service.
Authentication for Author Queries
Querying the Author instance requires authentication. Use service tokens (AEMaaCS) or basic auth (AEM 6.5 local development):
AEMaaCS: Service credentials
// Use Adobe IMS service credentials (never expose in client-side code)
async function fetchFromAuthor(queryPath: string) {
const token = await getServiceAccessToken(); // Adobe IMS token exchange
const response = await fetch(
`https://author-pXXXXX-eXXXXXX.adobeaemcloud.com/graphql/execute.json/${queryPath}`,
{
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
}
);
return response.json();
}
AEM 6.5 local development
# Basic auth for local dev
curl -u admin:admin \
"http://localhost:4502/graphql/execute.json/myproject/latest-articles"
Never embed Author credentials in client-side JavaScript. Author queries should only run from server-side code (API routes, serverless functions, build-time data fetching).
Error Handling
GraphQL responses include an errors array when queries fail:
{
"data": {
"articleList": {
"items": [
{ "title": "Valid Article", "body": { "html": "<p>...</p>" } }
]
}
},
"errors": [
{
"message": "Cannot resolve field 'nonExistentField'",
"locations": [{ "line": 5, "column": 13 }],
"path": ["articleList", "items", 1, "nonExistentField"]
}
]
}
Common error scenarios:
| Error | Cause | Fix |
|---|---|---|
Cannot resolve field | Field name doesn't match the model | Check the schema in GraphiQL |
Validation error | Required variable missing or wrong type | Pass all required variables with correct types |
403 Forbidden | CORS / referrer filter blocking | Configure CORS and referrer filter for the origin |
404 Not Found | Endpoint doesn't exist or wrong path | Verify endpoint name and configuration path |
401 Unauthorized | Missing or invalid auth for Author | Use service credentials or basic auth |
Empty items array | No fragments match filters, or fragments not published | Check fragment status and filter values |
Performance Optimisation
Use persisted queries (always)
Ad-hoc POST queries bypass Dispatcher and CDN caches. Always use persisted queries on Publish.
Limit query depth
Deeply nested fragment references can cause expensive JCR traversals:
# ❌ Bad: deeply nested (4 levels)
{
articleList {
items {
author {
articles {
relatedArticles {
author { name }
}
}
}
}
}
}
# ✅ Good: flat, fetch only what you need
{
articleList(limit: 10) {
items {
title
body { html }
author { name }
}
}
}
Request only needed fields
GraphQL lets you select exactly what you need. Avoid fetching unused fields - especially large
html bodies when you only need titles:
# ❌ Over-fetching
{ articleList { items { title body { html plaintext markdown json } author { name bio { html } } relatedArticles { title body { html } } } } }
# ✅ Fetch only what the UI needs
{ articleList { items { title publishDate category } } }
Use pagination
Never fetch unbounded lists. Always set limit and use offset or cursor-based pagination:
{ articleList(limit: 20, offset: 0) { items { title } } }
Add Oak indexes
For frequently filtered fields, ensure Oak indexes exist for the underlying JCR properties. See Performance for index configuration.
AEMaaCS vs AEM 6.5
| Feature | AEM 6.5 (SP18+) | AEMaaCS |
|---|---|---|
| GraphQL API | Available (requires feature pack on older SPs) | Built-in, always available |
| GraphiQL IDE | /content/graphiql.html | /aem/graphiql.html |
| Persisted queries | Supported | Supported (recommended) |
| CDN caching | Manual Dispatcher config | TTL-based (no purge on publish) |
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Using POST queries in production | Switch to persisted queries (GET); they are cacheable |
| CORS errors in the browser | Configure the CORS OSGi factory for your domain; include OPTIONS method |
GraphQL returns null for existing fields | The Content Fragment might not be published; check Publish instance |
| Nested references return empty | Referenced fragments must also be published to Publish |
| Slow queries | Limit depth, add pagination, create Oak indexes |
| Schema not showing new model fields | Publish the updated CF Model; the schema regenerates on Publish |
| Persisted queries not found on Publish | Persisted queries must be replicated to Publish |
| Cache not invalidating | On AEM 6.5, ensure Dispatcher flush covers .json; on AEMaaCS, verify publication/distribution and cache headers |
External Resources
- AEM GraphQL API Documentation
- CORS Configuration
- Referrer Filter Configuration
- GraphiQL IDE Tutorial
- AEM Headless Developer Portal
- Persisted Queries Documentation
See also
- Content Fragments - CF models, Java API, JCR structure
- Modify and Query the JCR - QueryBuilder and JCR-SQL2
- JCR Node Operations
- Architecture - Sling request pipeline
- Dispatcher Configuration
- Security - CSRF, referrer filter
- Edge Delivery Services
- AEM as a Cloud Service
- Performance - Oak indexes, caching
- Sling Models - server-side CF rendering