Sling Model Exporter
17 – Sling Model Exporter
Target: 3–4 years experienced AEM Developer Syllabus point covered (26): "What is the Sling Model Exporter?" Project domain: a global energy technology company's marketing site.
Before we start — where this fits
You now have three ways to get content out of AEM as JSON, and an interviewer who asks about the Model Exporter will very often follow up with "so when would you use this rather than GraphQL?"
That follow-up is the real question. Here is the answer in one line, and the rest of this file supports it:
The Sling Model Exporter gives you the page's structure as JSON. Content Fragments and GraphQL give you content that has no page structure at all.
Which means they suit different consumers:
- A consumer that mirrors the authored page — an SPA where a React component corresponds to an AEM component — wants the Model Exporter.
- A consumer with its own structure — a native mobile app, a partner system — wants Content Fragments and GraphQL (file 15).
If you can make that distinction, you've answered the question properly rather than describing an annotation.
1. Introduction
1.1 What it is
The Sling Model Exporter turns a Sling Model into JSON, served at a URL, without you writing a servlet.
You add one annotation to a model you already have:
And the model becomes available at:
That's it. No servlet, no serialisation code, no route registration.
1.2 The problem it solves
From file 07: when the browser needs data rather than a page, you write a servlet. And a lot of those servlets do exactly the same thing — read a resource, build an object, serialise it as JSON.
You already have something that reads a resource and builds an object. It's the Sling Model. The Model Exporter removes the duplication.
Without it:
With it: one annotation on the model, and the servlet disappears.
1.3 A real project example to adapt
"We use the Model Exporter in two places. The obvious one is a couple of components that expose their authored content as JSON for a front-end widget that loads asynchronously — the
.model.jsonendpoint means we didn't have to write a servlet for each.The more interesting decision was where not to use it. Our mobile app doesn't consume
.model.json, because that JSON mirrors our page structure — it's shaped like the page, with our component names and our nesting. The app has its own information architecture, so tying it to how we happen to lay out a web page would be a bad contract. That's Content Fragments and GraphQL instead.The thing we got wrong initially was treating the exported JSON as an implementation detail. It isn't — it's a public API contract. Renaming a getter renames a JSON field and breaks the consumer, so we use
@JsonPropertyto decouple the Java name from the JSON name."
That covers the use, the deliberate non-use with reasoning, and a genuine lesson — three follow-ups pre-empted.
2. Core Concepts
2.1 Making a model exportable
Three things are required, and the second one is where people get stuck.
1. @Exporter — this is what makes it exportable. name = "jackson" selects the Jackson serialiser; extensions = "json" says which extension it responds to. Using the ExporterConstants rather than string literals is the conventional form.
2. resourceType on @Model — this is required and it's the usual cause of a 404.
Think about how the request resolves (file 01): a request for /content/.../cta.model.json resolves the resource, reads its sling:resourceType, and then needs to find a model bound to that type with an exporter. Without resourceType on the @Model, there's no binding, so nothing is found.
3. Adaptable from the request — for the SPA Editor case specifically, and generally the safer choice for an exported model, because the exporter machinery works in a request context.
And a fourth thing that isn't strictly required but matters: adapters including the interface. That's the file 05 interface pattern, and here it also controls what gets exported.
2.2 The URL
On a component:
On a page:
model is a selector and json is an extension, which — from files 01 and 07 — means the URL is entirely path-based and therefore dispatcher-cacheable. That's a genuine benefit over a servlet taking query parameters.
But it also means the dispatcher has to allow it. The model selector and json extension need to be in the filter rules, or you get the same environment-specific 404 as every other path-allowlist problem in this repository.
2.3 What gets exported — and the security consequence
By default, every public getter on the model is serialised.
That is the single most important behavioural fact here, and it has a consequence people miss:
Adding a getter to an exported model changes your public API.
A getter you added for internal use — a helper the HTL calls, a debugging convenience, a path you resolve — becomes a field in JSON that anyone can fetch.
So @JsonIgnore is not optional hygiene; it's how you control the contract.
The interview point:
"The default is expose-everything — every public getter is serialised. That means adding a getter to an exported model silently changes the public API, which is easy to do without noticing. So
@JsonIgnoreon anything that isn't part of the contract, and I treat the exported JSON as an API rather than an implementation detail."
2.4 Controlling the output with Jackson annotations
@JsonProperty deserves emphasis, and this is the lesson from section 1.3:
The exported JSON is a contract with a consumer. If the JSON field name is derived from your Java getter name, then renaming getLinkUrl() to getUrl() — an ordinary refactor — silently renames a JSON field and breaks whoever is consuming it.
@JsonProperty decouples them. The Java name can change; the JSON name is stated explicitly and deliberately.
That's a genuinely good practice to volunteer, because it shows you think about the consumer.
2.5 ComponentExporter and the SPA Editor
This is where the Model Exporter gets architecturally interesting, and it's the reason it exists in the form it does.
The SPA Editor lets you author a React or Angular single-page application inside AEM. The SPA fetches a JSON representation of the page and renders it, and AEM's editor overlays authoring UI on top.
For that to work, the page has to be describable as JSON — the whole component tree, nested, with each component identifying its type so the SPA knows which React component to render.
Two interfaces make it work:
getExportedType() returns the resource type, which is how the SPA maps a piece of JSON to a component:
The resulting JSON is page-shaped:
The :-prefixed keys are structural metadata — :type, :items, :itemsOrder — while the unprefixed ones are the component's own content. That separation is deliberate, so a consumer can distinguish structure from data.
And notice :itemsOrder. JSON objects have no guaranteed key order, so the order has to be stated explicitly. That's a small design detail worth noticing, and it explains why ContainerExporter has two methods rather than one.
2.6 Model Exporter versus GraphQL — the question that follows
This is the comparison an interviewer will reach for, and it's the important part of the topic.
The distinction to state:
"The Model Exporter gives you the page's structure as JSON. It's shaped like the page — component names, nesting, our layout decisions. That's exactly right for the SPA Editor, where a React component corresponds to an AEM component and the SPA is deliberately mirroring the authored page.
It's the wrong contract for a native mobile app, because the app has its own information architecture. Tying it to how we happen to lay out a web page means our layout refactor breaks their app. That's Content Fragments and GraphQL — content that has no page structure at all, and the consumer picks the fields it needs.
So the question I'd ask is: does the consumer want to mirror our page, or does it have its own structure?"
2.7 When to use a custom servlet instead
Worth having, because the honest answer isn't always "use the exporter."
Use the Model Exporter when the JSON you want is essentially "this component's content."
Use a custom servlet (file 07) when you need something the model doesn't naturally produce — aggregating several resources, applying a filter passed in the request, returning a rendered HTML fragment rather than JSON, or shaping the response for a specific consumer.
The Load More endpoint from file 02 is the example. It returns rendered HTML, takes a batch number as a suffix, and clamps it. None of that is a model exporting itself, so a servlet is correct.
3. Internal Working
3.1 How a.model.json request resolves
graph TD A["GET /content/.../cta.model.json"] --> B["Sling resolves the RESOURCE"] B --> C["Reads sling:resourceType"] C --> D{"Is there a model registered<br/>for this resourceType<br/>WITH an @Exporter?"} D -->|No| E["Falls through to the<br/>DefaultGetServlet — you get<br/>raw node JSON, not your model"] D -->|Yes| F["Instantiate the model<br/>— injections, @PostConstruct"] F --> G["Jackson serialises<br/>every public getter"] G --> H["JSON response"]
The branch on the right is the diagnostic that matters.
If no model is found, Sling doesn't error — it falls through to the DefaultGetServlet from file 07, which renders the node as raw JSON. So you get some JSON back, just not yours.
That's a genuinely confusing symptom, because a naive check ("did I get JSON? yes") passes while the answer is wrong. The tell is that the output looks like raw node properties — jcr:primaryType, sling:resourceType — rather than your model's fields.
And the usual cause is a missing resourceType on @Model.
3.2 The full chain
Worth seeing because it ties several files together:
graph LR A["Request<br/>.model.json"] --> B["Resource<br/>file 01"] B --> C["Resource type<br/>file 02"] C --> D["Sling Model<br/>file 05"] D --> E["@PostConstruct<br/>runs once"] E --> F["Jackson serialises<br/>public getters"] F --> G["Cacheable JSON<br/>files 01, 07"]
Note @PostConstruct still runs, with everything from file 05 applying — expensive work happens once, and a model that returns null adapts to nothing.
And the caching point: because it's a selector-and-extension URL, it's path-based and the dispatcher caches it as a file. Same principle as everywhere else in this repository.
8. Coding Examples
8.1 An exported model, annotated
The five decisions to defend:
resourceType is present — and the comment explains the failure mode if it isn't.
@JsonProperty on every exported field, decoupling the JSON contract from Java names.
@JsonIgnore on internal getters, deliberately, because the default is expose-everything.
@JsonInclude(NON_NULL) so the output isn't cluttered with nulls.
getExportedType() returns the resource type, which is what the SPA maps against.
8.2 The resulting JSON
Note what's absent: isReady and style aren't there, because they're @JsonIgnore d. And resolvedLinkUrl isn't there — it's url, because @JsonProperty said so.
8.3 A container, for the SPA Editor
The :itemsOrder comment is the detail worth knowing — it explains why ContainerExporter has two methods rather than one, and it's a small design point that shows you've read the interface rather than just used it.
8.4 The dispatcher rule
8.5 What the SPA does with it
That's the payoff of getExportedType() — the SPA doesn't need to know anything about our page except the resource types, and AEM's editor can overlay authoring UI because the mapping is explicit.
9. Common Mistakes
10. Best Practices
Always set resourceType. Without it the export silently doesn't work, in a way that looks like it does.
Treat the JSON as a published API. @JsonProperty on every exported field so Java names can change freely. @JsonIgnore on everything not in the contract. Remember you can add fields safely but not rename or remove them.
Default to hiding. The framework's default is expose-everything, so the discipline has to come from you.
Keep getters cheap, because serialisation calls all of them. Work belongs in @PostConstruct.
Bound anything that builds a collection, especially container exports.
Allow the selector in the dispatcher, and plan invalidation for the cached JSON.
Choose deliberately between this and GraphQL. Page-shaped for a page-mirroring consumer; Content Fragments for anything with its own structure.
11. Debugging Tips
".model.json returns JSON but not my fields." Look at the output. If it contains jcr:primaryType and sling:resourceType, you're seeing the DefaultGetServlet rendering the raw node — Sling found no model for that resource type. The cause is almost always a missing resourceType on @Model.
"It returns 404." Either the resource doesn't exist at that path, or — on publish — the dispatcher isn't allowing the model selector. Check dispatcher.log to see whether the request reached AEM at all, the same split as everywhere else.
"A field is missing." Either @JsonIgnore is on it, or the getter returned null and @JsonInclude(NON_NULL) omitted it. Both are easy to forget you configured.
"A field appeared that shouldn't be there." Someone added a public getter. That's the expose-everything default doing what it does.
"Jackson can't serialise something." A getter returns a type Jackson doesn't know how to handle — a Resource, a Page, a JCR type. Return simple types or a small DTO instead.
12. Performance Notes
Serialisation calls every exported getter, so an expensive getter now costs on every JSON request as well as every HTL call. @JsonIgnore removes both the field and the call.
All of file 05 applies — @PostConstruct for the work, cheap getters, bounded collections.
Caching is the big lever. .model.json is a path-based URL, so the dispatcher serves it as a file and most requests never reach publish. That's a genuine advantage over a servlet taking query parameters.
Container exports can fan out. A container exporting children which export their children means a deep tree serialised in one request. Bound it.
14. Comparison Tables
Model Exporter vs Content Fragments + GraphQL vs custom servlet
The exporter interfaces
Jackson annotations
Next topic
18-Replication-and-Distribution — your syllabus point 27, and the last of the core set: what replication is, how agents work on 6.5, how Sling Content Distribution replaces them on Cloud Service, reverse replication, flush agents, and the queue problems that show up in production.
Topic 17 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.