For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/headless-aem-graphql-and-spa-editor.md.

Headless AEM: GraphQL and the SPA Editor

24 – Headless AEM: GraphQL and the SPA Editor

Target: 3–4 years experienced AEM Developer Where this sits: beyond the original 27-point syllabus (files 01–18) — the deeper headless story that comes up directly once an interviewer sees Content Fragments or the Model Exporter on your CV and wants to know if you can reason about the architecture, not just the API. Project domain: a global energy technology company's marketing site.


Before we start — one question, asked at the wrong level

File 15 asked "how do you read a Content Fragment" and answered it with the ContentFragment API. File 17 asked "how do you export a Sling Model" and answered it with @Exporter. Both files, near the end, converged on the same question:

Does the consumer mirror our page, or does it have its own structure?

That question is usually asked one delivery mechanism at a time — "Model Exporter or GraphQL?" — as if it's a technology choice. It isn't. It's an architecture question, and it has to be asked once, at the top, for the whole site, before any component gets built. Get it right there and the technology choices in files 15 and 17 fall out almost automatically. Get it wrong there and no amount of clever GraphQL modelling saves you.

And the honest answer, most of the time, for a site like ours, is not "headless." It's hybrid — most of the site stays a normal AEM-rendered website, and headless delivery exists only for the specific consumers that genuinely need it. Pure headless is the answer people reach for because it sounds more modern, and it is very often the wrong amount of architecture for the problem in front of them.

This file is about making that call correctly, and then executing the two hardest pieces once you've made it: GraphQL as a technology (going well past what file 15 needed for the basics), and the SPA Editor (which files 15 and 17 only touched via ComponentExporter).


1. Introduction

1.1 What "headless" actually means

A CMS is "headless" when it manages content but has no opinion about how that content is rendered. AEM in its traditional mode is the opposite of that: a page is a resource tree, HTL renders it, and the HTML that comes out is the product. There is no meaningful sense in which you could take that content and hand it to a completely different rendering technology — the content and its presentation are the same repository nodes, produced by the same components.

Headless delivery breaks that coupling. The content is exposed as data — JSON, mostly — with no rendering attached, and something else (a mobile app, a partner system, a separate front-end framework) decides how to show it.

That's a spectrum, not a switch, and knowing where you sit on it is the actual skill:

TraditionalHybridFull headless
Who renders HTMLAEM (HTL + components)AEM, for most of the siteNobody, in AEM — a separate front end does
What AEM deliversRendered pagesRendered pages and JSON, to different consumersOnly JSON/data
AuthoringIn-context, on the page (file 03)In-context for most content; structured fields for headless content (file 15)Structured fields only — no page to look at
SEOFree, from server-rendered HTMLFree, for the rendered partYour problem to solve again
Where most marketing sites belong—HereRarely, and usually for a reason unrelated to marketing pages

The row that matters for an interview: a site can be all three at once, for different parts of itself. Our 20 country marketing sites are traditional. The mobile app consuming product specifications is headless. The whole thing, taken together, is hybrid. That's not an inconsistency — it's the correct application of "use the right tool for the consumer," applied per consumer rather than per site.

1.2 Why pure headless is over-chosen

The pitch for full headless is real, and worth stating fairly, because a good answer doesn't strawman it: one API serving web, app, and partners alike; a front-end team free to use whatever framework they like; a clean separation between content and presentation that in theory ages well.

The part the pitch leaves out is everything AEM already gives you for free when you render pages traditionally. Editable templates and policies (file 03) give authors layout flexibility without a developer. MSM (file 12) handles twenty country sites inheriting and diverging from a common structure. The dispatcher caches rendered HTML at the edge with no extra engineering. Search engines get real HTML to crawl. None of that comes with full headless — you'd be rebuilding a content-management UI, a templating system, a caching layer and an SEO story, in whatever front-end framework you chose, because AEM's authoring and rendering machinery is exactly the part you switched off.

So the honest test is: does the thing you're building actually need its own structure, its own release cycle, its own rendering technology — or are you choosing headless because it's the fashionable word, and a normal AEM page would do the job with a fraction of the engineering? Section 7's first story is what happens when a team answers that question the second way.

1.3 The decision framework

The one-line version, inherited from file 17 and elevated to the whole architecture:

Does the consumer mirror our page, or does it have its own structure?

Which expands into four practical questions, and asking them in this order gets you to the right mechanism fastest:

  1. Does this need to render as an actual web page — crawlable, cacheable, editable in context? If yes, that's traditional AEM rendering, headless or not.
  2. Does the consumer's data model look like our page — the same components, in the same nesting, just consumed by a different renderer (a React app that's supposed to look like the AEM page)? That's the SPA Editor / Model Exporter territory (file 17).
  3. Does the consumer have its own information architecture entirely — a native app screen, a partner's data feed, something with no concept of "page" at all? That's Content Fragments and GraphQL (file 15).
  4. Is the requirement one specific, oddly-shaped piece of JSON or HTML that doesn't fit either of the above — an aggregation, a computed value, a request-driven filter? That's a custom servlet (file 07).

Section 2 goes through all four, plus a fifth thing that isn't really a fourth option so much as a constant companion: how images and binaries get delivered underneath whichever of the above you pick.


2. Core Concepts

2.1 The four delivery mechanisms, side by side

Before going deep on any one of them, it's worth having the whole map in view, because the interview question "why not just use GraphQL for everything" is really asking whether you understand this map.

MechanismShape of the JSONRight forCovered in depth
Content Fragments + GraphQLContent-shaped — your content model, no page structureNative apps, partners, anything with its own structureFile 15, and section 2.3 below
Sling Model ExporterPage-shaped — your component tree, nesting, layoutThe SPA Editor, or any consumer deliberately mirroring the pageFile 17, and section 2.4 below
Custom servletWhatever you writeAggregation, request-driven filtering, rendered HTML, a bespoke shapeFile 07, and section 2.5 below
Traditional HTML (no JSON at all)N/A — it's a rendered pageThe default for anything that's genuinely a web pageFiles 02, 03, 08

The trap to avoid stating badly: these aren't ranked from best to worst. They're not even mutually exclusive on the same site — our site uses three of the four, for three different consumers, at the same time. The skill being tested is picking correctly per consumer, not picking a favourite.

2.2 GraphQL in AEM — the schema you didn't write

File 15 established that Content Fragment Models generate a GraphQL schema automatically. Here's what that actually produces, because "it generates a schema" is the kind of sentence that sounds fine until someone asks you what's in it.

For a Content Fragment Model — say, product, with fields productName, voltageRange, coolingType, a manufacturer fragment reference and a relatedProducts fragment reference list — the schema generates, roughly:

  • A GraphQL type for the model itself (Product), with one field per model field. Text fields become String, numbers become Int or Float, dates become a date scalar, and — this is the part worth remembering from file 15 — a multi-line text field becomes an object with both html and plaintext, so the consumer picks the rendering it wants without AEM guessing.
  • A "by path" query, conventionally shaped like productByPath(_path: String!), which fetches exactly one fragment given its repository path and returns an item wrapping the Product type.
  • A "list" query, conventionally shaped like productList(filter: ProductFilter, sort: String, offset: Int, limit: Int), which fetches many fragments of that model, with generated filter arguments per field — so you can ask for every product where coolingType equals a given value, without writing any query logic yourself.
  • Fragment reference fields become nested object types. manufacturer isn't a path string in the GraphQL schema — it's a full Manufacturer object, queryable in the same request. That convenience is also section 2.2.3's warning.

You write none of this. Create the model, enable GraphQL for the configuration (section 2.2.4), and the schema exists. That's the entire pitch of Content Fragment Models being headless-native, and it's also why the model-design discipline from file 15 section 3.3 matters even more here — the GraphQL schema is generated from the model, so a badly designed model produces a badly designed API, automatically, without anyone deciding that on purpose.

2.2.1 Nested queries, and what "for free" actually costs

Because a fragment reference becomes a nested object, a single GraphQL query can walk an arbitrary depth of your content graph in one request:

query getProductDeep($path: String!) {
  productByPath(_path: $path) {
    item {
      productName
      manufacturer {
        manufacturerName
        certifications {
          certificationName
          issuingBody
        }
      }
      relatedProducts {
        productName
        manufacturer {
          manufacturerName
        }
      }
    }
  }
}

Read that query slowly and count the reads it triggers. One product. One manufacturer, plus however many certifications that manufacturer has. Then, for every related product, another manufacturer lookup. If relatedProducts has ten entries, that's ten more fragment reads just for the second-to-last field in the query, and if any of those manufacturers had their own nested references, it keeps going.

That's the fan-out risk, and it is the single most important thing to be able to talk about when GraphQL comes up, because it's the failure mode that doesn't show up in a demo with three test fragments and absolutely shows up in production with a real content graph. Section 7's second story is exactly this, in numbers.

The fix is modelling, not query-writing. A field like relatedProducts should very often not be a fragment reference that resolves to the full nested object — it should be a lighter reference (a path, a name, a thumbnail) that the consumer only expands when it actually needs the full related product, on demand, in a second query. Section 8.1 shows both the expensive shape and the fixed one.

2.2.2 Variables

Ad-hoc queries take variables the normal GraphQL way — a variables JSON object alongside the query in the POST body:

{
  "query": "query getProduct($path: String!) { productByPath(_path: $path) { item { productName } } }",
  "variables": { "path": "/content/dam/energy/fragments/products/tx-4000" }
}

Persisted queries take variables differently, and this is the detail that makes the caching story in section 2.2.3 work at all: the variables are appended to the URL as matrix parameters, semicolon-separated, so the whole request — query and its inputs — is a single cacheable path:

GET /graphql/execute.json/energy/getProductByPath;path=/content/dam/energy/fragments/products/tx-4000

Multiple variables just chain with more semicolons:

GET /graphql/execute.json/energy/getProductList;coolingType=oil-immersed;limit=20

Why this matters more than it looks like it should: a query parameter (?path=...) is not guaranteed to be treated as part of the cache key the same way a path segment is, across every layer of a caching stack (file 01's "a dot is cached, a question mark isn't" principle, arriving again). Matrix parameters are still part of the path, so the dispatcher and CDN cache ;path=tx-4000 and ;path=tx-5000 as two entirely separate files, correctly, with no special configuration.

2.2.3 Persisted queries — the production answer, in depth

File 15 covered why persisted queries matter (caching, and constraining callers). Here's the rest of the mechanism.

Creating one. A persisted query starts life as an ordinary GraphQL query you've tested — usually in GraphiQL (section 2.2.5) against a development or author instance. Once it's correct, it gets saved server-side under the configuration it belongs to, with a name you choose. From that point on, calling it by name and passing variables in the path is equivalent to POSTing the full query text — except now it's a GET.

Where they're stored. Persisted queries live under:

/conf/<project>/settings/graphql/persistentQueries/<queryName>

The same /conf tree as templates, policies and Content Fragment Models (files 03 and 15) — which is worth noticing, because it means persisted queries are configuration that ships and versions the same way everything else under /conf does, not something bolted on separately.

The URL format, stated precisely:

/graphql/execute.json/<configurationName>/<queryName>;var1=value1;var2=value2
  • <configurationName> is the Sites configuration the query was created under — the same configuration that has GraphQL enabled in the first place (section 2.2.4).
  • <queryName> is whatever the query was saved as.
  • Everything after the semicolons is variables, in the path, as covered above.

Caching, precisely. Because that whole thing is one GET on one stable path, it behaves exactly like any other cacheable AEM URL from file 01: the dispatcher caches the response as a file on disk, keyed by the full path including the matrix parameters, and the CDN caches it at the edge in front of the dispatcher. A mobile app that queries the same handful of persisted queries on every launch, for a catalogue that changes rarely, ends up serving almost all of that traffic from the CDN — publish barely sees it.

The second benefit, restated because it's the one people forget to mention: persisted queries are a security boundary, not just a performance one. An ad-hoc endpoint accepts any query a caller cares to construct — including one that walks ten levels of fragment references and brings a publish instance to its knees, or one that pulls fields nobody intended to expose externally. A persisted-queries-only publish tier means the only queries that can ever run are the ones your team wrote, reviewed, and saved. That's the same "constrain, don't validate" principle from file 11's dialog discipline, applied to an API surface instead of a form field.

2.2.4 The endpoint isn't on by default

GraphQL for Content Fragments has to be switched on, per Sites configuration, in the Configuration Browser (Tools → General → Configuration Browser). Enabling it is what makes that configuration's name usable as the <configurationName> segment in both the ad-hoc endpoint and the persisted-query URL.

That's a deliberate default, not an oversight, and the reasoning matters for the security conversation in section 2.2.6: a site that never intends to deliver headlessly shouldn't have a GraphQL surface sitting there to be discovered and probed. Enable it only for the configurations that actually back a headless consumer.

2.2.5 GraphiQL — for development, never for production

GraphiQL is an interactive query IDE — write a query, run it against a real instance, see the schema, see the response, iterate. It's how you'd actually build and validate a persisted query before saving it.

It belongs on development and author instances, and nowhere near a public publish tier. It is, by design, a tool for constructing arbitrary ad-hoc queries against your schema — which is precisely the capability section 2.2.6 says publish should not expose. Treat "is GraphiQL reachable from here" as a security check with the same seriousness as "is CRXDE reachable from here."

2.2.6 Securing GraphQL on publish

Pulling the last few sections together into the actual publish-tier checklist, because this is where the interview question "how would you secure this" gets answered concretely rather than in the abstract:

  1. Enable GraphQL only on configurations that need it. Section 2.2.4 — it's opt-in per configuration, so opt in narrowly.
  2. Dispatcher rules block the ad-hoc endpoint on publish. The ad-hoc query path accepts a POST with an arbitrary query body — that's a caller running whatever they like against your schema, and it should not be reachable from the public internet at all. Deny it in the dispatcher filter, the same way /bin/receive is denied in file 18.
  3. Only the persisted-query path is allowed, and only as a GET — /graphql/execute.json/*. That's the one you actually let the internet call.
  4. GraphiQL and any development tooling package are not installed on publish at all — not just blocked at the dispatcher, genuinely not present.
  5. Anonymous still needs read on the underlying fragment paths, same as file 15 — securing the query surface doesn't remove the DAM permission requirement underneath it.

The interview-ready version of this list is short: "Ad-hoc off, persisted-only, GraphiQL nowhere near publish, and the usual DAM read permissions underneath."

2.2.7 Invalidating cached query responses

This is a derived-cache problem, and file 18 already gave the general shape of it: publishing content invalidates the page that content is on, and nothing else automatically. A persisted query's cached response is exactly the kind of "nothing else" that gets forgotten.

When a Content Fragment referenced by a persisted query gets activated, that fragment's own dispatcher-cached representation (if it has one) gets flushed by the ordinary flush-agent mechanism (file 18). But the query response — which is a different cached artifact, derived from the fragment's content but living at a different path — does not automatically know it depends on that fragment. Nobody wired that dependency for you.

The fix is the same one file 18 already reached for a cached listing built from a page tree: a replication event handler that reacts to ReplicationAction.EVENT_TOPIC, works out which persisted queries could plausibly be affected by the fragment that just published, and issues a targeted flush for those cached responses — doing almost nothing on the event thread itself and dispatching a Sling Job to do the actual work (file 10's discipline, applying here too).

Skip this and the failure mode is quiet and bad: the fragment updates, the app's next launch still serves the cached response from an hour — or a day — ago, and nobody gets an error, because nothing is wrong from the dispatcher's point of view. It's serving exactly what it was told to cache.

2.3 The Sling Model Exporter, one layer up — page-shaped delivery

File 17 covered @Exporter, ComponentExporter, and the component-level JSON shape in full, and there's no point repeating it here. What that file didn't need yet, because it was reasoning about one component, is what happens when the whole page gets exported — which is exactly what the SPA Editor needs.

2.3.1 The page-level page model

Requesting .model.json on a page resource, rather than a component, returns the whole authored tree — and it carries two structural keys that file 17 only mentioned in passing:

{
  ":type": "energy/components/page",
  ":path": "/content/energy/global/en/products",
  ":hierarchyType": "page",
  ":items": {
    "root": {
      ":type": "wcm/foundation/components/responsivegrid",
      ":items": { "cta": { ":type": "energy/components/cta", "text": "Read more", "url": "/global/en/news" } },
      ":itemsOrder": ["cta"]
    }
  },
  ":itemsOrder": ["root"],
  ":children": {
    "products/tx-4000": {
      ":type": "energy/components/page",
      ":hierarchyType": "page",
      ":title": "TX-4000 Transformer"
    }
  }
}

:items and :itemsOrder are exactly what file 17 already explained for a container component — the page's own component tree.

:hierarchyType tells the consumer this node represents a page, as distinct from a component. That distinction matters because a page-level model mixes two kinds of thing in one JSON document: the page's own authored content, and pointers to other pages underneath it — and a consumer needs to be able to tell which is which without guessing from context.

:children is the site's page hierarchy, lazily represented — child pages, keyed by their relative path, generally with a shallow representation (title, type) rather than their full content. Why shallow, and not the whole subtree in one response: exporting every descendant page's entire component tree in a single request would mean a request for the site root returning, effectively, the whole site as one JSON blob. :children gives the SPA's router enough to know a child page exists and what it's called, and the SPA fetches that child's own .model.json only when the user actually navigates there.

That's the same fan-out discipline as section 2.2.1, arriving from a different mechanism. Whether it's a GraphQL fragment reference or a page's :children, the pattern that keeps a JSON API workable is the same one: shallow by default, expand on demand.

2.4 The SPA Editor, in depth

2.4.1 The problem it solves

Say the front-end team already has a React (or Angular) component library, or the page genuinely needs the interactivity a single-page application gives you — instant navigation with no full-page reload, complex client-side state, a multi-step tool that recalculates as the visitor changes inputs. Building that as HTL components (file 08) fights the technology: HTL renders server-side, once, and any interactivity after that is bolted-on JavaScript reaching back into a server-rendered DOM.

The alternative — building it as a plain SPA with no AEM involvement at rendering time — solves the technical problem and creates an authoring problem. A plain SPA has no concept of "drag this component here" or "click this text to edit it." Authors would be editing structured fields with no live page in front of them, which is a real step backward from what AEM's Page Editor normally gives them (file 03).

The SPA Editor is the answer to both halves at once: the front end genuinely is a React or Angular application, running its own client-side rendering and routing — and an author working in AEM's Page Editor can still click on a piece of it, get the normal component dialog, and see the change reflected live, the same way they would on an HTL-rendered page.

2.4.2 The architecture

graph TD A["Author edits in AEM Page Editor"] --> B["Page Editor loads the page,<br/>which runs the SPA's own JS bundle<br/>(built by ui.frontend, file 04)"] B --> C["SPA fetches the page's<br/>.model.json via ModelManager"] C --> D["SPA renders the tree client-side,<br/>React/Angular components<br/>bound by MapTo per resource type"] D --> E["AEM's editor overlays authoring UI<br/>on top of the SPA's own DOM —<br/>click targets, component toolbars"] E --> F["Author edits a component's dialog"] F --> G["ModelManager re-fetches the<br/>changed portion of the model"] G --> D

The mechanism in one sentence: the SPA is the thing that's actually rendering the page — AEM doesn't render HTL for these pages at all — and it renders from the same page model JSON that file 17's exporter machinery produces, with a JavaScript layer on the AEM side making that JSON tree editable in place.

Two pieces make this work, and they map exactly onto file 17's exporter interfaces plus a JS counterpart:

  • ComponentExporter / ContainerExporter (Java, file 17) produce the :type, :items, :itemsOrder tree — the data the SPA needs to know what to render and in what order.
  • ModelManager (JavaScript) is what the SPA uses to actually fetch, cache and react to changes in that tree.

2.4.3 ModelManager and the page model lifecycle

ModelManager is a singleton from the AEM SPA Editor JS SDK (@adobe/aem-spa-page-model-manager) that owns the page model on the client side. Its job, stated as three responsibilities:

  • Fetch. ModelManager.initialize(path) requests the root page's .model.json and populates an in-memory model. Deeper parts of the tree — the :children from section 2.3.1 — get fetched lazily, the same shallow-by-default pattern.
  • Cache. Once fetched, the model lives in memory for the SPA's own components to read via ModelManager.getData(path), rather than every component independently requesting the same JSON.
  • React to change. When an author edits something in Page Editor, AEM tells the running SPA that part of the model changed, and ModelManager re-fetches just that part and emits an event. Components listening for that path re-render with the new data — which is what makes the edit feel live, rather than requiring a manual page refresh.

The interview-sized version: " ModelManager is the SPA's single source of truth for the page model. It fetches .model.json, caches it, and re-fetches and notifies the SPA when AEM says a piece of the page changed underneath it."

2.4.4 MapTo — binding a resource type to a component

File 17 already showed the shape of this from the JavaScript side:

MapTo('energy/components/cta')(CtaComponent);

What this actually does: it registers, in a global mapping the SPA Editor SDK maintains, that any node in the page model whose :type equals energy/components/cta should be rendered by CtaComponent. When the tree walker hits that node, it looks up the mapping and renders the matched component with that node's fields as props.

That mapping is the entire contract between AEM and the SPA. AEM knows nothing about React internals; the SPA knows nothing about how the component was authored in a dialog. The resource type string is the only thing both sides agree on — which is exactly the same resource-type-as-contract idea that underlies HTL's data-sly-resource and Sling's request resolution (file 01, file 02), just crossing a language boundary this time instead of staying inside Java.

2.4.5 EditableComponent — making a React component authorable

A plain MapTo 'd component renders the content. It does not, on its own, know how to behave inside AEM's Page Editor — how to show an empty-component placeholder when there's nothing authored yet, how to expose the right DOM attributes for the editor's click targets and drag handles, how to signal its boundaries to the overlay.

EditableComponent (from @adobe/aem-react-editable-components, or the Angular equivalent) is a wrapper that adds exactly that. Wrap the plain component with it, and:

  • It renders an empty placeholder when the component has no authored content and the page is in edit mode — the SPA's version of file 05's isReady() guard, except the "nothing to render" case has to be visibly clickable rather than invisible, because an author needs to be able to click on it to add content.
  • It attaches the data attributes AEM's editor overlay reads to know where this component's DOM boundaries are — which is how the overlay draws a toolbar around the right region and knows what a click on that region means.
  • It participates in the re-render cycle described in 2.4.3, so an edit to this specific component updates only this component's rendered output.

Why this is a real cost, not a formality: every component that needs to be authorable in the SPA Editor needs both halves — the Java Sling Model implementing ComponentExporter (file 17), and a React/Angular component wrapped in EditableComponent and bound with MapTo. That's two implementations of the same component, in two languages, kept in sync by hand. Section 7's third story is what that cost looks like when you multiply it across an entire site.

2.4.6 Remote SPA versus the integrated SPA Editor

There are two genuinely different deployment shapes here, and conflating them is a common mistake.

The integrated pattern — what's described above — packages the SPA's build output as a clientlib inside AEM's ui.apps, served from the same origin, loaded by the page component the normal way. Page Editor loads that same origin, so the SPA and the editor overlay share a browser context with no cross-origin friction, and full in-context editing works the way it's described in section 2.4.2.

A remote SPA is hosted and deployed independently — its own domain, its own build and release pipeline, its own CDN — fetching .model.json from AEM over the network (typically CORS) rather than being served by AEM at all. That buys the front-end team genuine deployment independence: they can ship on their own schedule, on their own infrastructure, without an AEM release in the loop.

What it costs is authoring fidelity. A remote SPA isn't running inside the same page AEM's editor is decorating, so the tight, real-time overlay-and-click experience of the integrated pattern needs real integration work to approximate at all, and some of it may not be achievable to the same standard. The honest trade to state: deployment independence versus in-context editing quality — and a team should make that trade deliberately, having actually looked at both, rather than defaulting to whichever one a tutorial happened to show them.

2.4.7 The honest trade-offs — when the SPA Editor is not worth it

This is the part of the topic that separates someone who's read the docs from someone who's shipped it.

The SPA Editor adds real, ongoing complexity:

  • Two technology stacks for one component. Every authorable component needs a Java Sling Model (for the exporter) and a React/Angular component (for the SPA) — file 17's exporter work, doubled, forever, for every new component the site adds.
  • Server-side rendering doesn't come for free. A plain client-side-rendered SPA sends the browser a near-empty HTML shell and lets JavaScript build the page — which is a real problem for a marketing site whose value proposition includes organic search traffic. Getting server-rendered (or pre-rendered) HTML back requires its own infrastructure — commonly a Node SSR service — layered on top of AEM and the dispatcher, which is more moving parts, not fewer.
  • Routing has two owners. The SPA has its own client-side router; AEM has its own page hierarchy. Reconciling the two — especially across a multi-country, multi-language structure (file 12) — is integration work that a traditional site simply doesn't have.
  • Debugging spans two runtimes. A rendering bug might be in the Java model, the JSON it exported, or the React component consuming it — and diagnosing which means moving between two toolchains instead of one.

So the honest test to apply, out loud, in an interview:

"Does this page genuinely need to be a single-page application — real client-side state, instant navigation, a live-recalculating tool — or would a normal AEM page, HTL and a bit of client-side JavaScript, do the job? Most marketing pages are the second kind. The SPA Editor is worth its cost for the minority that are genuinely the first."

Section 7's third story is a team reaching exactly that conclusion, after prototyping rather than guessing.

2.4.8 The ui.frontend relationship

File 04 already introduced ui.frontend as the module where modern front-end code lives — a webpack/npm project, built independently, with its output copied into a clientlib under ui.apps by aem-clientlib-generator.

For a SPA Editor project, ui.frontend is where the entire React or Angular application lives, not just some CSS and progressive-enhancement JavaScript. The build output is the whole SPA bundle — ModelManager initialisation, every MapTo 'd and EditableComponent -wrapped component, the works — packaged as a clientlib and loaded by a (very thin) page component whose only real job is to render the container the SPA mounts into.

The consequence worth stating: the front-end team's day-to-day workflow doesn't change much from file 04's description — they work in ui.frontend with normal npm tooling, without needing a running AEM instance for most of their work — but what they're building inside that module is categorically bigger than a CSS/JS bundle. It's an application, and it needs the AEM SPA Editor SDK wired into it from day one, not bolted on afterward.

2.5 Custom servlets as a delivery mechanism

File 07 already covers servlets in full; this is specifically about when a servlet is the right headless delivery choice, as opposed to GraphQL or the exporter.

The test, restated for this context: does the JSON (or HTML fragment) you need correspond to one fragment's content (→ GraphQL) or one component's content, page-shaped (→ Model Exporter) — or does it require logic that neither of those naturally expresses?

A concrete example from our site: a "compare transformers" endpoint. The comparison table needs to pull two or three product fragments, apply unit conversions so a European and a North American spec sheet compare on the same scale, apply a business rule about which fields are even comparable between two different product families, and return a shape built for that one screen. That's not "give me a fragment's fields" (GraphQL already does that) and it's not "give me a component's authored content" (Model Exporter already does that) — it's a computation over several pieces of content, and a servlet is exactly the tool for a computation.

The caching property carries over from file 07 unchanged: a servlet responding to a path-and-selector request (compare.json with a suffix, say) is just as dispatcher-cacheable as anything else path-based; a servlet reading query parameters isn't. The mechanism choice doesn't change the caching rules — it just changes who's writing the logic.

2.6 Assets — the delivery leg nobody's schema covers

Every one of the mechanisms above delivers text and structured data. None of them is how you'd want to deliver a 4MB product photograph.

A Content Fragment's "content reference" field (file 15) typically points at a DAM asset — an image, most often. GraphQL returns that reference as a path, not as image bytes; embedding binary data in a GraphQL response would be a bad use of the format and a bad use of the cache. The consumer takes that path and requests the actual image separately, directly against the DAM — as a plain rendition URL, or through AEM's dynamic media/asset-delivery capability if the project has it configured, which can serve resized, format-negotiated renditions on the fly from parameters in the URL rather than requiring a fixed rendition to be pre-generated for every size a consumer might want.

The point worth making explicitly: a "headless delivery" architecture is never only GraphQL. It's structured content via GraphQL, page structure via the exporter where relevant, and binary assets delivered separately, by path, cached like any other asset. Missing that third leg is how a team ends up with a beautifully modelled product API that returns an image path the mobile app then can't actually resolve because nobody thought about asset permissions on publish (file 15's DAM-permission point, again, from a new direction).

2.7 Hybrid, in practice

Pulling sections 2.1 through 2.6 together into what our site actually does:

  • The 20 country marketing sites render traditionally — Core Components, editable templates, MSM. No GraphQL, no SPA Editor, on any of those pages.
  • The mobile app consumes product specifications through Content Fragments and GraphQL persisted queries (file 15), because it has its own screens and its own information architecture that has nothing to do with how a web page is laid out.
  • A partner dealer portal consumes the same GraphQL layer, for the same reason — it's a different consumer with its own structure, and it's cheap to add once the schema and persisted queries exist for the app.
  • One interactive configurator tool — and only that one page type — runs as an SPA Editor instance, because it's the one place on the site with genuine client-side, stateful, recalculating behaviour that a normal HTL component would fight rather than deliver naturally (section 7's third story).
  • A comparison endpoint is a custom servlet, because comparing normalised specs across product families is a computation, not a content fetch.

That's five different answers to "how do we deliver this," on one site, and every one of them is correct — because each answer was chosen per consumer, against the decision framework in section 1.3, rather than picked once for the whole program and applied uniformly. That's the argument this whole file is making, restated as an inventory.


3. Internal Working

3.1 A GraphQL persisted query, end to end

sequenceDiagram participant App as Mobile app participant CDN as CDN edge participant Disp as Dispatcher participant Pub as Publish participant CFM as Content Fragment App->>CDN: GET /graphql/execute.json/energy/getProduct#59;path=tx-4000 alt Cached at the edge CDN-->>App: Cached JSON response else Not cached CDN->>Disp: forward request alt Cached at dispatcher Disp-->>CDN: Cached JSON file else Not cached Disp->>Pub: forward request Pub->>CFM: resolve fragment(s), run generated resolvers CFM-->>Pub: fragment data Pub-->>Disp: JSON response Disp-->>Disp: cache as a file end Disp-->>CDN: response CDN-->>CDN: cache at the edge end CDN-->>App: JSON response

The point this diagram is making: almost every request in a healthy setup terminates at the CDN or the dispatcher, and publish only ever sees traffic the first time a given persisted query with a given set of variables is requested after content changes. That's the entire performance case for persisted queries, drawn as a picture instead of stated as a sentence.

3.2 What invalidates what

graph TD A["Content Fragment is activated<br/>(file 18 — a replication event)"] --> B["Flush agent invalidates the<br/>fragment's OWN dispatcher cache entry"] A --> C{"Is there a replication event<br/>handler watching for this?"} C -->|No| D["Persisted query responses<br/>derived from this fragment<br/>stay cached — STALE"] C -->|Yes| E["Handler dispatches a job (file 10)<br/>to flush the specific cached<br/>query responses affected"] E --> F["Dispatcher/CDN re-fetch<br/>on next request"]

The branch that gets missed in production is the left one. Nothing in AEM wires a fragment's activation to a persisted query's cached response automatically — that relationship has to be built, the same way file 18 already said for any derived, cached output. A team that ships persisted queries without also shipping this handler ships a system that looks fine in testing (nobody's waiting long enough for staleness to matter) and serves outdated specifications quietly in production.

3.3 The fan-out mechanism, drawn

Reading the diagram as a cost model: one query, one variable (path), and a resolution cost that grows with M times N, not with 1. That's the shape every fan-out problem takes — a query that reads like a single request and resolves like a nested loop. Section 8.1 shows the fix, which is always the same fix: stop nesting the expensive field, and let the consumer ask for it explicitly, separately, only when it needs it.

3.4 How a.model.json request becomes an editable SPA page

graph LR A["Author opens Page Editor"] --> B["Page component loads<br/>(thin HTL shell)"] B --> C["SPA bundle from ui.frontend<br/>clientlib boots"] C --> D["ModelManager.initialize()<br/>fetches page.model.json"] D --> E["Tree walked; each:type<br/>resolved via MapTo"] E --> F["EditableComponent wrappers<br/>expose editor click-targets"] F --> G["AEM overlay renders<br/>toolbars on top of SPA DOM"]

This is the same URL→resource→resource-type→model chain file 17 drew for one component (its section 3.2), run once for the whole page and then handed to a JavaScript runtime instead of stopping at the server. Nothing about the underlying Sling resolution changes — what changes is who consumes the JSON at the far end.


8. Coding Examples

8.1 A fan-out query, and the fixed version

The expensive shape — a list of fragment references resolving to full nested objects:

# EXPENSIVE: relatedProducts resolves as a list of FULL Product objects,
# each of which pulls its OWN manufacturer and certifications. Cost
# grows with the number of related products TIMES their own nesting —
# not with the single "path" variable this query appears to take.
query getProductDeepExpensive($path: String!) {
  productByPath(_path: $path) {
    item {
      productName
      manufacturer {
        manufacturerName
        certifications { certificationName issuingBody }
      }
      relatedProducts {                 # a LIST of full Product objects
        productName
        manufacturer {                  # ...each with its OWN manufacturer
          manufacturerName
          certifications { certificationName issuingBody }
        }
      }
    }
  }
}

The fixed shape — shallow references, expanded on demand:

# FIXED: relatedProducts now returns just enough to LINK to the related
# product -- path, name, a thumbnail. The app fetches the full related
# product only if the user actually opens it, via a SECOND query below.
query getProductShallow($path: String!) {
  productByPath(_path: $path) {
    item {
      productName
      manufacturer {
        manufacturerName
        certifications { certificationName issuingBody }
      }
      relatedProducts {
        _path                # just enough to fetch the full item later
        productName
        thumbnail { _path }
      }
    }
  }
}

# Called separately, ONLY when the user opens a related product.
# Same shape as the first query -- reused, not duplicated.
query getProductByPath($path: String!) {
  productByPath(_path: $path) {
    item { productName manufacturer { manufacturerName } }
  }
}

Why the fix is in the model as much as the query. Even with the shallow query above, if relatedProducts in the Content Fragment Model is defined in a way that always eagerly resolves the full object with no shallow option, the query text alone can't fix it. The lasting fix is designing the field, at the model level, as a lightweight link — a content reference or a bounded list — rather than an unlimited fragment reference to the full type, so the cheap shape is the only shape available, not just the one this particular query happened to ask for.

8.2 Saving and calling a persisted query

What gets saved server-side, under /conf/energy/settings/graphql/persistentQueries/getProductShallow (conceptually — this is done through the GraphQL configuration tooling, not hand-authored):

query getProductShallow($path: String!) {
  productByPath(_path: $path) {
    item {
      productName
      voltageRange
      manufacturer { manufacturerName }
      relatedProducts { _path productName }
    }
  }
}

Calling it, as a GET, with the variable in the path:

GET /graphql/execute.json/energy/getProductShallow;path=/content/dam/energy/fragments/products/tx-4000

Calling it with more than one variable:

GET /graphql/execute.json/energy/getProductList;coolingType=oil-immersed;limit=20;offset=0

The comment worth keeping next to any of this in real code or documentation: everything after the query name is part of the path, not a query string, which is precisely why the dispatcher and CDN can cache each distinct combination of variables as its own file, with zero special cache-key configuration.

8.3 Dispatcher rules securing the GraphQL surface on publish

# Allow ONLY the persisted-query GET path -- this is the entire public
# GraphQL surface on publish. Variables arrive as matrix parameters in
# the same path, so this one rule covers every persisted query and
# every combination of variables, and every one of them is cacheable.
/0100 { /type "allow" /method "GET" /url "/graphql/execute.json/*" }

# Deny the AD-HOC endpoint outright. It accepts a POST with an
# arbitrary query body -- letting the internet run any query against
# the schema is exactly what persisted queries exist to prevent.
/0110 { /type "deny" /url "/content/_cq_graphql/*" }

# GraphiQL and any GraphQL development tooling should not even be
# INSTALLED on a publish instance -- this rule is a backstop, not the
# actual control.
/0120 { /type "deny" /url "*/graphiql*" }

8.4 A replication event handler invalidating cached query responses

package com.energy.core.listeners;

import com.day.cq.replication.ReplicationAction;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventHandler;
import org.osgi.service.event.EventConstants;

// Same shape as file 18's derived-cache invalidation pattern and file
// 10's job-dispatch discipline -- do almost nothing on the event
// thread itself, because a bulk publish can fire hundreds of these.
@Component(
        service = EventHandler.class,
        property = { EventConstants.EVENT_TOPIC + "=" + ReplicationAction.EVENT_TOPIC }
)
public class GraphQlCacheInvalidationListener implements EventHandler {

    private static final String FRAGMENT_ROOT = "/content/dam/energy/fragments/products";

    @Reference
    private JobManager jobManager;

    @Override
    public void handleEvent(Event event) {
        String path = (String) event.getProperty("path");

        // Cheap relevance check ONLY. The real work -- working out
        // which persisted query responses this fragment could affect,
        // and flushing them -- happens in a job, not here.
        if (path != null && path.startsWith(FRAGMENT_ROOT)) {
            jobManager.addJob("energy/jobs/graphql-cache-flush",
                    java.util.Collections.singletonMap("fragmentPath", (Object) path));
        }
    }
}

The job itself (elsewhere, per file 10's pattern) would resolve which persisted queries reference that fragment path — directly, or via a productByPath / productList result that could include it — and issue a dispatcher flush for those specific cached response paths, rather than flushing everything.

8.5 A page-level SPA Editor page model

GET /content/energy/global/en/products.model.json
{
  ":type": "energy/components/page",
  ":path": "/content/energy/global/en/products",
  ":hierarchyType": "page",
  ":items": {
    "root": {
      ":type": "wcm/foundation/components/responsivegrid",
      ":items": {
        "configurator": {
          ":type": "energy/components/configurator",
          "productFamily": "transformers"
        }
      },
      ":itemsOrder": ["configurator"]
    }
  },
  ":itemsOrder": ["root"],
  ":children": {
    "products/tx-4000": {
      ":type": "energy/components/page",
      ":hierarchyType": "page",
      ":title": "TX-4000 Transformer"
    },
    "products/tx-5000": {
      ":type": "energy/components/page",
      ":hierarchyType": "page",
      ":title": "TX-5000 Transformer"
    }
  }
}

Note what :children does NOT contain — the child pages' own :items. That's deliberate: the SPA's router knows two child pages exist and what they're called, and fetches either one's own .model.json only when a visitor actually navigates there.

8.6 The SPA side — ModelManager, MapTo, EditableComponent

// index.js -- the SPA's entry point, built by ui.frontend (file 04)
// and packaged as a clientlib the thin page component loads.

import { ModelManager } from '@adobe/aem-spa-page-model-manager';
import { withMappable } from '@adobe/aem-react-editable-components';
import './components/Configurator';   // registers itself via MapTo, below

// Fetches the root page's .model.json, and everything downstream --
// EditableComponent wrappers, MapTo lookups -- reads from what this
// call populates. Nothing renders before this resolves.
ModelManager.initialize({ path: window.location.pathname })
    .then((rootModel) => {
        renderApp(rootModel);   // hand off to React's render, omitted here
    });
// components/Configurator.js -- one authorable component

import React from 'react';
import { MapTo, EditableComponent } from '@adobe/aem-react-editable-components';

const RESOURCE_TYPE = 'energy/components/configurator';

// The PLAIN component -- knows how to render the tool. Knows NOTHING
// about AEM's editor, click targets, or empty-state placeholders.
const ConfiguratorPlain = ({ productFamily }) => (
    <div className="cmp-configurator">
        {/* the actual interactive, stateful tool -- omitted here */}
        <span>Configuring: {productFamily}</span>
    </div>
);

// EditableComponent adds: an empty-placeholder when there's no
// authored content in edit mode, the DOM attributes AEM's overlay
// reads to draw a toolbar around this component, and participation in
// ModelManager's re-render cycle when an author edits this node.
class Configurator extends EditableComponent {
    render() {
        return <ConfiguratorPlain {...this.props} />;
    }
}

// The ONLY thing AEM and the SPA agree on: this resource type maps to
// this component. AEM knows nothing about React; this file knows
// nothing about how the dialog that authored \`productFamily\` works.
MapTo(RESOURCE_TYPE)(Configurator);

The comment worth keeping visible in a real codebase: every component that reaches this level of AEM integration exists in two places — this file, and a Java Sling Model implementing ComponentExporter for the same resource type (file 17, section 8.1). Neither one is optional, and neither one knows the other exists beyond the shared resource type string.

8.7 A custom servlet for a computed comparison

package com.energy.core.servlets;

import com.adobe.cq.dam.cfm.ContentFragment;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.apache.sling.servlets.annotations.SlingServletPaths;
import org.osgi.service.component.annotations.Component;

import javax.servlet.Servlet;
import java.io.IOException;

// This is NOT "one fragment's fields" (GraphQL already does that) and
// NOT "one component's content" (the exporter already does that). It's
// a COMPUTATION over several fragments with business rules applied --
// unit conversion, comparability rules -- which is exactly what a
// servlet is for (file 07).
@Component(service = Servlet.class)
@SlingServletPaths("/bin/energy/compare")
public class CompareTransformersServlet extends SlingSafeMethodsServlet {

    private static final int MAX_COMPARE = 4;   // bounded, as everywhere

    @Override
    protected void doGet(SlingHttpServletRequest request,
                          SlingHttpServletResponse response) throws IOException {

        // Suffix carries the paths to compare -- path-based, so this
        // request is STILL dispatcher-cacheable, unlike a servlet
        // reading query parameters (file 07's rule, unchanged here).
        String[] paths = request.getRequestPathInfo().getSuffix() != null
                ? request.getRequestPathInfo().getSuffix().split(",")
                : new String[0];

        if (paths.length == 0 || paths.length > MAX_COMPARE) {
            response.setStatus(SlingHttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        response.setContentType("application/json");
        // Resolve each fragment, apply unit conversion and the
        // comparability business rule, and write a shape built for
        // this ONE screen -- not the generic fragment shape GraphQL
        // would give you, and not the page shape the exporter would.
        for (String path : paths) {
            Resource fragmentResource = request.getResourceResolver().getResource(path);
            ContentFragment fragment = fragmentResource != null
                    ? fragmentResource.adaptTo(ContentFragment.class)
                    : null;
            if (fragment != null) {
                // ... normalise and write this fragment's comparable fields ...
            }
        }
    }
}

9. Common Mistakes

The mistakeWhat happensThe fix
Choosing full headless for the whole site by defaultRebuilding a CMS, templating and caching layer that AEM already gave you freeDecide per consumer — section 1.3
Modelling a fragment reference list as full nested objectsGraphQL fan-out — response time and payload explode on real dataShallow references; expand on demand
Testing GraphQL queries only against a handful of demo fragmentsFan-out isn't caught until production, with real content volumeTest against a realistically sized sample
Using ad-hoc GraphQL in productionNot cacheable, and callers can run any queryPersisted queries only, on publish
Leaving GraphiQL reachable from publishAnyone can construct arbitrary queriesNot installed at all on publish
No replication event handler for persisted query cachesFragments update; cached responses stay stale silentlyA handler that flushes the specific affected queries
Using .model.json for a native appCouples the app to the web page's layoutContent Fragments + GraphQL instead
Adopting the SPA Editor site-wideDoubles every component's implementation cost, foreverScope it to pages that genuinely need SPA behaviour
No SSR plan for a public SPA Editor pagePoor first paint; SEO risk on organic-traffic pagesBudget SSR/pre-rendering infrastructure up front
Treating query parameters as cacheable in a persisted queryNot actually cached the same wayMatrix parameters, in the path
Forgetting resourceType on an exported model feeding the SPAFalls through to the DefaultGetServlet (file 17)Set resourceType
Embedding image bytes in a headless content modelBad fit for the format, breaks cachingDeliver images by path, separately (section 2.6)
No limit on a fragment reference listAn unbounded list keeps getting worse over timeA ceiling at the model or component level
Building a React counterpart with no matching ComponentExporterThe SPA has nothing to map to for that resource typeImplement both sides together, deliberately

10. Best Practices

On the architecture decision. Ask "does this consumer mirror our page, or have its own structure?" per consumer, not once for the whole program. Treat hybrid as the default answer, not a compromise on the way to "real" headless.

On GraphQL. Enable it only on configurations that need it. Design fragment reference fields to resolve shallow by default; make "expand the full related item" an explicit, separate query rather than an automatic nested one. Persisted queries only on publish, with the ad-hoc endpoint and GraphiQL blocked or absent.

On invalidation. Plan the replication-event-handler-to-cache-flush relationship at the same time you design the persisted query, not after the first stale-content bug report.

On the SPA Editor. Prototype the specific page type before committing to it site-wide. Budget the doubled component cost and the SSR question explicitly, as real line items, not assumptions.

On testing GraphQL. Validate query shapes against realistically sized content, not a handful of demo fragments — fan-out is invisible at demo scale by construction.

On images. Deliver them by path, separately from the structured content query, and check anonymous read on publish the same way file 15 already taught for the text content.


11. Debugging Tips

"A GraphQL query is slow or the response is huge." Check the query for list-shaped fragment reference fields, and check whether any of them nest further reference fields. That's fan-out, and it's a modelling fix, not a query-tuning one.

"The app shows stale product data." Almost always a missing or incomplete cache-invalidation relationship between fragment activation and the persisted query's cached response — file 18's derived-cache problem, one layer further out.

"A GraphQL request that should be blocked on publish isn't." Check the dispatcher rules for the ad-hoc endpoint path specifically — allowing /graphql/execute.json/* too broadly, or forgetting the explicit deny on /content/_cq_graphql/*, both look fine until someone actually tries a POST.

"The SPA renders nothing for a component." Check for a MapTo registration matching that resource type, and separately check that the Java model actually implements ComponentExporter with resourceType set (file 17's failure mode, arriving here as a blank spot in the SPA instead of raw JSON).

"An author can't edit a component in the SPA." The component is probably mapped but not wrapped in EditableComponent — it renders content but has none of the authoring affordances.

ToolAnswers
GraphiQL (dev/author only)Does the query resolve, and what does the response actually look like
Response size/time on a realistic sampleIs there fan-out
/conf/<project>/settings/graphql/persistentQueriesWhich queries are saved, and their exact text
Dispatcher rules for /graphql/execute.json and /content/_cq_graphqlIs the publish surface correctly scoped
Browser devtools network tab, on the SPAIs .model.json being fetched, and what it contains
error.logReplication event handler and job failures

12. Performance Notes

Caching is the whole performance story, across every mechanism. A persisted GraphQL query, .model.json, and a well-built servlet are all cacheable exactly because they're path-based GETs — the moment any of them takes a query parameter or a POST, that stops being true.

Fan-out is the failure mode that only shows up at real content scale. Nested fragment references and deep container exports both resolve eagerly, in one request, so a shape that looks cheap in a demo can be genuinely expensive against production content. Test against realistic data, not demo fragments.

Invalidation granularity cuts both ways. No invalidation means stale content served forever; invalidating everything on every publish means every cache miss recomputes work nothing actually changed for. Aim for targeted invalidation of the specific queries or pages actually affected.

The SPA Editor's specific performance cost is client-side rendering. Without server-side rendering or pre-rendering, the first meaningful paint depends entirely on the browser executing JavaScript — a cost a server-rendered HTL page simply doesn't have.


15. Comparison Tables

The four delivery mechanisms, head to head — the table the brief for this file asks for directly:

Content Fragments + GraphQLSling Model ExporterCustom servletTraditional HTML
ShapeContent-shapedPage-shapedWhatever you writeRendered markup, no JSON
Coupled toNothing — fragments stand aloneYour component treeYour designYour components and templates
Best forNative apps, partnersThe SPA EditorAggregation, computed shapesThe default for real web pages
Setup costModel design + query/persisted-query setupOne annotation per modelA full servletNone beyond a normal component
CacheableYes — persisted queries are GETsYes — selector-and-extensionYes, if path-basedYes — the whole point of the dispatcher
Query flexibilityConsumer selects fieldsNone — fixed by the modelWhatever the servlet supportsNone — it's rendered, not queried
Authoring impactStructured fields, no live page viewNormal Page Editor, plus SPA if usedNone — it reads existing contentFull in-context editing
Maintenance costModel discipline; watch for fan-outKeep @JsonProperty / @JsonIgnore deliberateBespoke code to maintainLowest — AEM's own machinery

Full headless vs hybrid vs traditional

TraditionalHybridFull headless
Who renders the pageAEMAEM, for most consumersNobody, in AEM
SEOFreeFree, for the rendered partRebuilt elsewhere
In-context editingFullFull, for traditional pagesNone — structured fields only
Typical fitMarketing pagesMost real sitesA consumer with no "page" at all

SPA Editor vs remote SPA

Integrated SPA EditorRemote SPA
HostedInside AEM, as a clientlibIndependently, its own domain
Fetches the modelSame originCross-origin (CORS)
In-context editing fidelityFullRequires real integration work to approach
Deployment independenceTied to AEM releasesFully independent
ui.frontend 's roleBuilds the whole app for AEM's clientlibBuilds the whole app for its own pipeline

Ad-hoc vs persisted GraphQL (full detail in file 15; restated for completeness)

Ad-hocPersisted
MethodPOSTGET
CacheableNoYes
Callers can runAny queryOnly approved queries
Belongs on publishNoYes

Next topic

25-Maven-Packages-and-Project-Structure — how an AEM project is actually laid out as Maven modules (core, ui.apps, ui.content, ui.config, ui.frontend, all), what each one is responsible for, how the build assembles and deploys them, and the dependency mistakes that show up when a project's structure doesn't match what these files have assumed throughout.


Topic 24 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.