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/component-development.md.
Your syllabus has one line for this topic, and it hides a trap:
"Tell me a complex component you have developed recently."
That question is not asking you to define a component. It is asking you to prove you have built things. Interviewers use it as a lie detector. Someone who has only read about components describes one in general terms. Someone who has actually built one mentions the dialog structure, the multifield that fought them, and the thing that broke on publish.
So this file does three jobs.
First, it makes sure you genuinely understand how a component is assembled — every file, what it does, and why. That is sections 1 to 3.
Second, it gives you three complete components at three difficulty levels, fully coded:
Level Component Answer length What it proves
Simple CTA link/button ~1 minute You know dialog basics and the classic traps
Medium FAQ accordion ~2 minutes You can handle multifields, nested models and accessibility
Complex Tabbed product listing with Load More ~3 minutes You think about queries, caching and architecture
Interviewers often warm up with "start with something simple" before pushing to "now tell me the most complex one." Having a graded set means you are never caught either over-explaining a trivial component or under-explaining a hard one.
Third, it grounds every example in a real, coherent project so your stories hang together across all 40+ files of this repository.
A note on the project domain
Every example in this repository is set on a global energy technology company's marketing site — the kind that sells transformers, HVDC systems, grid automation and power quality equipment to utilities and industrial customers.
This is a genuinely good domain to talk about in interviews, because it naturally justifies the things AEM interviewers care about:
A large, deep product catalogue — hundreds of product category pages, which forces derived listings rather than hand-authored ones.
Many countries and languages — URLs like /us/en/... and /in/en/... alongside a global site, which is a natural MSM and language-copy conversation.
Technical documentation and PDF downloads — a real integration story.
SEO-driven content like FAQ sections, because industrial buyers search technical questions.
High but not consumer-scale traffic, so caching matters without the story becoming implausible.
Important: never name the client in an interview, and this repository never does. "A global energy technology company" or "an industrial energy client" is the correct level of detail. Naming clients is a professionalism red flag, and interviewers do notice.
⚠️ Read this before using any story in this file
These stories are shapes, not scripts. Interviewers at companies like Publicis Sapient and Valtech will drill three or four levels into anything you say. If you claim you fixed a caching problem, the next four questions will be about hit ratios, statfiles, and what you measured.
So fill these shapes with things you can actually defend. Support experience counts — "I traced a stale-content issue to a blocked replication queue" is a real, honest, technical story. A POC you built counts. WKND tutorial work counts. A borrowed story you cannot defend does more damage than having no story at all.
Introduction
1.1 What is a component, really?
Most people answer with "a reusable piece of a page." True, but shallow, and it tells an interviewer nothing.
Here is a more useful definition:
A component is a folder under /apps containing everything needed to render one type of content and to let an author edit it.
Read that again, because both halves matter.
"Everything needed to render" — the HTL file, and usually a Sling Model holding the logic.
"And to let an author edit it" — the dialog. This is what makes AEM a CMS rather than a templating engine. Without a dialog you have hardcoded HTML. With one, a content author changes the page without a developer.
That second half is where most interview follow-ups live, and it is the part beginners consistently underestimate.
1.2 How a component connects to content
Component development only makes sense if you remember how Sling works, so let's connect back to file 01.
Recall the chain: URL → Resource → Resource Type → Script.
A component is the "Resource Type → Script" half. When an author drags a CTA onto a product page, AEM creates a node and sets one property:
/content/energy/global/en/products-and-solutions/transformers/jcr:content/root/container/cta
├── sling:resourceType = "energy/components/cta" ← points at your component
├── linkText = "Read the Press Release"
└── linkUrl = "/content/energy/global/en/news/press-releases/2026/grid-upgrade"Copy
Copy
That sling:resourceType is the entire link between content and code. Sling reads it, goes to /apps/energy/components/cta, finds cta.html, and runs it.
So a component is just a folder that a content node points at. Once that lands, a lot of AEM stops being mysterious — including why a component "disappears" when someone renames a folder, and why copying content between projects breaks rendering.
1.3 Why we build custom components at all
Adobe ships Core Components — around thirty ready-made components covering text, image, teaser, carousel, tabs, accordion, breadcrumb, navigation, forms and more. They are accessible, tested, and Adobe maintains them.
So the honest question in a modern interview is not "how do you build a component" but "when should you build one at all?"
The answer that scores well:
"My default is to use a Core Component and extend it, not build from scratch. They're accessible, tested, and Adobe keeps them updated — that's free maintenance I don't want to give up. I build custom only when the requirement genuinely has no Core Component equivalent, like a listing that derives its cards from the page tree. And even when I extend, I do it through a proxy so we control the version."
That one answer tells the interviewer you have worked on a real, maintained project rather than a tutorial.
1.4 The decision tree
Here is what I would actually reason through, and it makes a good whiteboard answer:
Does a Core Component roughly do this already?
→ Yes, and I only need it to look different → Style System. No code at all. → Yes, but I need one or two extra fields → Extend via sling:resourceSuperType, add to the dialog via the Resource Merger. → Yes, but the markup is fundamentally wrong for our design system → Extend and override only the HTL. → No, this is genuinely new behaviour → Build custom, but still follow Core Component patterns.
Notice how far down "build custom" sits. That ordering is the answer.
1.5 A project description to adapt
"On my current project — a global energy technology company's marketing site — we have around 45 components. About 25 are proxies to Core Components with small dialog additions, so we wrote almost no code for those. Around 10 are Core Components with custom HTL because the design system needed different markup. And about 8 are genuinely custom, mostly listings that derive their content from the page tree rather than being hand-authored, plus the components that integrate with our document publishing system."
That is strong because it shows proportion. A candidate who says "we built 45 custom components" is telling the interviewer their project has a maintenance problem.
Core Concepts
2.1 The anatomy of a component — every file explained
Let's open a real component folder and go through it file by file. You should be able to draw this from memory.
/apps/energy/components/cta/
│
├── .content.xml ← the component definition itself
├── cta.html ← the HTL script that renders it
│
├── _cq_dialog/
│ └── .content.xml ← the author dialog (Touch UI)
│
├── _cq_editConfig.xml ← how it behaves in the page editor
│
├── _cq_template/
│ └── .content.xml ← default content when first dropped
│
└── clientlibs/ ← optional CSS/JS just for this componentCopy
Copy
A note on the underscore naming. In the repository these nodes are called cq:dialog, cq:editConfig and cq:template. But a colon is not a legal filename character, so FileVault — the tool that serialises the repository to disk — writes them as _cq_dialog, _cq_editConfig, _cq_template.
Same node, different spelling depending on whether you are looking at CRXDE or your IDE. This confuses people constantly, and knowing it is a small credibility signal.
2.2 .content.xml — the component definition
This file is what says "this folder is a component." Without it, the folder is just a folder.
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:Component"
jcr:title="Call to Action"
jcr:description="A single link styled as a button or arrow link"
componentGroup="Energy - Content"/>Copy
Copy
Let me explain each property, and — more usefully — what breaks if you get it wrong.
jcr:primaryType="cq:Component" — this is what makes it a component. Missing or wrong, and AEM does not recognise the folder at all. It will never appear anywhere in the UI.
jcr:title — the name authors see in the component browser. Make it human-readable; authors are not developers.
jcr:description — the tooltip. Usually skipped, and authors quietly suffer for it.
componentGroup — which group the component appears under in the component browser. This one has behaviour worth knowing: if componentGroup is empty or set to .hidden, the component does not appear in the component browser at all.
That is deliberate, not a bug. It is how you hide components meant only for internal use — for example the individual item inside a carousel. An author should never drag a "carousel item" onto a page; only the carousel should create them.
This is a real interview question: "Your component doesn't show up in the component browser. Why?" Two answers: componentGroup is empty or .hidden, or the component is not in the allowed list of the template's policy. Give both and you have answered completely.
sling:resourceSuperType — the inheritance pointer, covered next. It is the single most important concept in modern component development.
2.3 The proxy component pattern — the most important concept here
This gets its own section because it comes up in every AEM interview involving Core Components, and getting it right marks you as current rather than someone who learned AEM in 2016.
Start with the problem it solves.
Core Components live outside your project — their code sits under /apps/core/wcm/components/.... Now imagine an author drags Adobe's Accordion directly onto a page. The content node gets:
sling:resourceType = "core/wcm/components/accordion/v1/accordion"Copy
Copy
That resource type is now baked into hundreds of content nodes across your site. Three real problems follow:
One — you cannot customise anything. If the design system needs different markup or one extra field, you have nowhere to put it. You would have to edit Adobe's component, which lives outside your project.
Two — you do not control version upgrades. When Core Components ship v2 of the accordion, your content still says v1. Moving to v2 means rewriting every affected content node.
Three — your content depends on Adobe's internal paths. That is coupling you never want.
The solution is the proxy component. You create a tiny component in your own project that inherits:
/apps/energy/components/accordion/.content.xml
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:Component"
jcr:title="Accordion"
componentGroup="Energy - Content"
sling:resourceSuperType="core/wcm/components/accordion/v1/accordion"/>Copy
Copy
That is the entire file. No HTL. No dialog. No Java. Just a pointer.
Now authors use your accordion, content stores energy/components/accordion, and look at what you gained:
Selective override. Want different markup? Add an accordion.html in your folder and Sling uses yours — but everything you did not override still comes from the Core Component.
Version control. Upgrading is a one-line change in the proxy. Not a single content node changes.
Decoupling. Your content depends only on your own paths.
The interview answer:
"A proxy component is a component in our own project whose only real content is a sling:resourceSuperType pointing at a Core Component — essentially an empty shell. We always do this for control. Content nodes then reference our resource type instead of Adobe's, so we can override the HTL or part of the dialog later without touching content, and upgrading Core Components from v1 to v2 becomes a one-line change in the proxy rather than a content migration across hundreds of nodes. Adobe's own documentation recommends never using Core Components directly."
How inheritance resolves. When Sling needs accordion.html for resource type energy/components/accordion:
/apps/energy/components/accordion/accordion.html ← yours, if it exists
→ not found, so follow sling:resourceSuperType
/apps/core/wcm/components/accordion/v1/accordion/accordion.html ← Adobe'sCopy
Copy
The same fallback applies to the dialog, the edit config, everything. That is why a proxy with a single .content.xml works perfectly — it all falls through to the parent.
2.4 The HTL file — how the script gets found
The rendering script is an HTL file, and there is a naming rule that catches everyone once:
The HTL file must be named after the component's folder.
So /apps/energy/components/cta/ needs cta.html. Not index.html, not main.html.
Why? Go back to script resolution in file 01. One rule was "a script named after the last segment of the resource type." The resource type is energy/components/cta, last segment cta, so Sling looks for cta.html. It is the same rule you already learned, applied to components — not a special component rule.
2.5 The dialog — where authors actually work
The dialog is the form an author fills in. It lives at _cq_dialog/.content.xml, written as a tree of Granite UI resource types.
Be honest with yourself: dialog XML is verbose and nobody memorises it. What interviewers test is whether you understand its structure and the handful of properties that cause bugs.
The structural skeleton. Every Touch UI dialog nests the same way:
cq:dialog
└── content (container)
└── items
└── tabs (optional)
└── items
└── properties (one tab)
└── items
└── columns (layout)
└── items
└── your actual fieldsCopy
Copy
That is a lot of nesting for a text field, but there is a rhythm: container → items → container → items, repeating. Once you see it, you can read any dialog.
Now the properties that actually cause bugs.
name="./title" — the ./ prefix is not optional.
This is the single most common dialog bug in AEM, and it is asked about constantly.
./ means "relative to the current component's node." Without it, the value is written to the parent node instead. The dialog appears to save — no error, no warning — but your Sling Model reads nothing, because the property landed one level up.
So when asked "the author fills the dialog and clicks save, but the value doesn't appear", the first check is a missing ./.
required="{Boolean}true" — note the {Boolean} type hint. XML attributes are strings by default. {Boolean} tells FileVault to store a real boolean. Write required="true" without it and you store the string "true", which some Granite fields ignore. Same for {Long} on numbers.
fieldLabel and fieldDescription — the label and help text. Authors judge your component almost entirely on these.
emptyText — placeholder shown when empty.
The field types worth knowing by name:
What you need Resource type (under granite/ui/components/coral/foundation/form/ unless noted)
Single line of text textfield
Multiple lines textarea
Rich text editor cq/gui/components/authoring/dialog/richtext
Pick a page or asset pathfield
Dropdown select
Checkbox checkbox
On/off toggle switch
Number numberfield
Date datepicker
Radio buttons radiogroup
Hidden value hidden
Upload a file fileupload
Repeatable set of fields multifield
Tag picker cq/gui/components/coral/common/form/tagfield
Colour picker colorfield
2.6 Multifields — the one authors always ask for
A multifield lets an author add a repeating set of values — a list of links, a set of FAQ questions, a group of specifications. It appears in almost every project, and in interviews, because there are two kinds and candidates mix them up.
Simple multifield — one value per row.
Values are stored as a String array on one property.
Copy
Copy
Stored as:
applications = ["Utilities", "Railways", "Data Centers"] ← one multi-value propertyCopy
Copy
Read in Java as String[] or List.
Composite multifield — several fields per row.
Each row becomes its own child node.
Copy
Copy
Stored as:
faqs/
├── item0/
│ ├── question = "What is a transformer in electricity?"
│ └── answer = "
They transfer electrical energy between circuits...
"Copy
Copy
Read with @ChildResource into a List.
The three things that make composite multifields work, and that people forget:
composite="{Boolean}true" on the multifield. Without it, only the first field saves.
The field node must be a container, not a form field.
name="./faqs" goes on the container, and inner fields use plain relative names. The paths combine.
The interview question: "Difference between simple and composite multifield?"
"A simple multifield stores a multi-value string property on the component node — one value per row. A composite sets composite=true and wraps its fields in a container, so each row becomes a child node like faqs/item0, faqs/item1, each holding several properties. I read the simple one as a String[], and the composite one with @ChildResource into a list of a nested model class."
2.7 cq:editConfig — behaviour in the editor
This controls what happens in the page editor, not on the published page. It is optional, but there are things you can only do here.
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:EditConfig"
cq:actions="[text:CTA,-,edit,copymove,delete,insert]"
cq:dialogMode="floating"
cq:layout="editbar">
<cq:listeners
jcr:primaryType="cq:EditListenersConfig"
afteredit="REFRESH_SELF"/>
</jcr:root>Copy
Copy
cq:actions — which buttons appear on the toolbar.
cq:listeners — what happens after an edit. REFRESH_PAGE reloads the whole page; REFRESH_SELF reloads just this component.
This matters for a real reason. By default AEM refreshes only the component. Fast, but if your component's change affects something outside itself — say a filter driving a list below it — the page looks wrong until the author reloads manually. afteredit="REFRESH_PAGE" fixes it.
That is genuinely good to mention in an interview, because it shows you have watched real authors use your component.
cq:dropTargets — lets an author drag an asset from the DAM onto the component. propertyName says where the path is written, accept restricts MIME types.
cq:inplaceEditing — lets the author edit text directly on the page instead of opening the dialog.
2.8 cq:template — default content
When an author drops your component, sometimes you want sensible defaults rather than an empty box.
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured"
linkText="Explore more"
style="arrow"/>Copy
Copy
These properties are copied onto the new node the moment the component is added.
Do not confuse this with a page template. Same word, completely different thing. cq:template inside a component is default content for that component. A page template — file 03 — defines a whole page's structure. Interviewers occasionally use this overlap as a trick question.
2.9 Component-level clientlibs
You can put a clientlibs folder inside a component so its CSS and JS travel with it. Full detail is in file 04, but the relevant idea:
The advantage is cohesion — everything about the component lives in one folder, and deleting the component removes its styles too.
The catch is that a component clientlib still has to be loaded by something. It does not load automatically just because the component is on the page. Either the page component includes it by category, or you embed it into a site-wide bundle.
This trips people up: they put CSS in a component clientlib, see nothing, and assume it is broken. It is not broken — nobody asked for it.
2.10 Types of component
Not every component is a box on a page. Categorising them shows architectural awareness.
Content components — the ordinary kind. CTA, text, image, teaser.
Container components — components holding other components. cq:isContainer="{Boolean}true", renders a parsys inside. Tabs and accordions are usually containers.
Structure components — the page component itself, header, footer. Placed by the template, not by authors.
Utility / hidden components — things like a carousel's individual item. They exist so a container can create them. That is exactly what componentGroup=".hidden" is for.
Internal Working
3.1 What happens when an author drops a component
Understanding this sequence explains several bugs at once.
sequenceDiagram
participant A as Author (browser)
participant E as Page Editor
participant P as Sling Post Servlet
participant J as JCR
participant S as Sling render
A->>E: drags "CTA" into a containerE->>P: POST to the container pathP->>J: create node "cta"P->>J: set sling:resourceType = energy/components/ctaP->>J: copy properties from cq:templateJ-->>E: node createdE->>S: request the component HTMLS->>S: resolve resource type → find cta.htmlS-->>E: rendered markupE-->>A: component appears on the page
⛶ Fullscreen
Now the author opens the dialog and saves:
sequenceDiagram
participant A as Author
participant D as Dialog
participant P as Sling Post Servlet
participant J as JCR
A->>D: opens dialog, types link text
D->>P: POST ./linkText=Explore more to the component node
P->>J: write property "linkText" on that node
J-->>P: saved
P-->>D: success
D->>A: dialog closes, component re-renders
⛶ Fullscreen
Here is the insight worth carrying away: notice that you never wrote a servlet to save that dialog. AEM did not generate one either.
The Sling Post Servlet — from file 01 — handles it. It is Sling's built-in handler for POST to a repository path, and its default behaviour is "take the form fields and write them as properties on the node at this path."
That is why ./ matters so much. The POST goes to the component's node path, and ./linkText means "the property linkText on the node I'm posting to." Drop the ./ and the field name is addressed differently, so the value lands somewhere you did not intend.
This connects three things you now know: dialogs are HTML forms, the Sling Post Servlet writes them to the repository, and ./ is the addressing scheme. Explain it that way and it sounds like understanding, not memorisation.
3.2 How the resource super type chain resolves
Let's make this precise, because interviewers push on it.
Say you have:
/apps/energy/components/accordion
sling:resourceSuperType = "core/wcm/components/accordion/v1/accordion"Copy
Copy
and your folder contains only .content.xml and a custom accordion.html.
HTL script — yours is found immediately and used. Dialog — not found in your folder, so Sling follows sling:resourceSuperType and uses Adobe's. Edit config — same, Adobe's is used.
So you get Adobe's dialog and editing behaviour with your own markup. Selective override. That is the whole point.
The follow-up is almost always: "How do you add one extra field to an inherited dialog without copying the whole thing?"
The Sling Resource Merger — from file 01. You create a dialog containing only the new field, plus sling:orderBefore to position it. Sling merges your partial dialog with the inherited one instead of replacing it.
Copy
Copy
You can also remove an inherited field with sling:hideResource="{Boolean}true", or hide properties with sling:hideProperties.
Naming the Resource Merger and giving these three properties is a strong intermediate answer.
3.3 The decoration wrapper — where those extra divs come from
You write clean HTL, and the rendered page has
elements you never wrote.
Those come from the decoration tag. AEM wraps each component in a div so the page editor has something to attach its toolbar and click handlers to.
Usually fine. But sometimes it breaks your CSS — particularly with flexbox and grid, where an unexpected wrapper changes the layout entirely.
Three ways to control it:
Per include, in HTL:
Copy
Copy
Always, on the component: cq:noDecoration="{Boolean}true" in .content.xml.
Change the tag rather than remove it:
Copy
Copy
That last one matters when the wrapper must be a valid child — inside a
, a
is invalid HTML but an
is correct.
A caution worth mentioning: removing decoration entirely makes a component harder to select in the editor, because the editor loses the element it attaches to. Usually the better answer is changing the tag name, not removing the wrapper.
Important Interview Questions
4.1 Basic
Q1. What is a component in AEM?
A component is a folder under /apps that contains everything needed to render one type of content and to let authors edit it: an HTL script, a dialog, and usually a Sling Model behind it. The component node has the type cq:Component. Content is linked to it through the sling:resourceType property, so every node that names the component is rendered by it.
Cross: Where do they live? · What node type? (cq:Component) · What links content to a component?
Q2. What files make up a component?
A component folder usually contains .content.xml, which defines the component and its properties; an HTL file named after the folder, which renders it; and _cq_dialog, the form authors use to configure it. Optional parts include _cq_editConfig for editor behaviour, _cq_template for default values when the component is added, and a clientlib for its CSS and JavaScript. Strictly speaking, only .content.xml is mandatory.
Cross: Why _cq_dialog on disk but cq:dialog in CRXDE? · Which are mandatory? (strictly, only .content.xml) · What if there is no dialog? (authors can't configure it)
Q3. Why must the HTL file be named after the folder?
Because of Sling's script resolution rules. One rule looks for a script named after the last segment of the resource type. For energy/components/cta, the last segment is cta, so Sling looks for cta.html in that folder. If the file has any other name, Sling will not find it unless another rule matches, such as a selector-specific script like print.html.
Cross: What if you name it something else? (not found, unless another rule matches) · Can a component have several HTL files? (yes — selector-based, like print.html)
Q4. What is componentGroup?
componentGroup sets the group under which a component appears in the page editor's component browser, such as "Energy – Content". If the value is .hidden, the component never appears there, which is useful for helper components such as accordion items that should only be created by their parent. Note that a component can also be missing because the template's policy does not allow it.
Cross: Why hide one deliberately? (helper components like accordion items) · Is that the only reason it might not appear? (no — template policy is the other)
Q5. What is sling:resourceSuperType?
sling:resourceSuperType is the inheritance pointer between components. When Sling cannot find a script, dialog or other file in a component, it looks in the component named by this property, and continues up the chain through as many levels as exist. This is what makes the proxy pattern possible: a proxy inherits everything from a Core Component and overrides only what it needs.
Cross: Difference from sling:resourceType? · Can you inherit through several levels? (yes) · What does it enable? (the proxy pattern)
Q6. What is cq:dialog?
cq:dialog is the Touch UI dialog — the form authors fill in to configure a component. It is built from Granite UI field types arranged in containers, tabs and items, and each field writes its value to a property on the component's content node. It replaced the old Classic UI dialog, and in editable templates the old design dialog has been replaced by policies.
Cross: What was the old one? (dialog, Classic UI — deprecated) · What is cq:design_dialog? (design dialog for static templates; replaced by policies in editable templates)
Q7. What does ./ mean in name="./linkText"?
It tells the Sling POST Servlet to store the value relative to the component's own content node, so ./linkText becomes the property linkText on that node. Without ./, the value is written somewhere else and the component silently reads nothing. The same servlet also understands suffixes such as @TypeHint to set a property's type and @Delete to remove a property.
Cross: What processes that POST? (Sling Post Servlet) · What is @TypeHint? · How do you delete a property? (@Delete)
Q8. What is a Core Component?
Core Components are Adobe's library of ready-made, production-quality components, such as Title, Text, Image, Teaser and Container. They are accessible, well tested, versioned and released independently of AEM itself. Projects should not use them directly in content; instead, each one is wrapped in a project-specific proxy component that inherits from it.
Cross: Where do they live? · Why version folders like v1, v2? · Should you use them directly? (no — proxy them)
Q9. What is cq:editConfig for?
cq:editConfig controls how a component behaves inside the page editor. It configures toolbar actions, listeners such as REFRESH_PAGE after an edit, drop targets for dragging assets onto the component, and in-place editing. It has no effect on the published page, and it is optional — most simple components do not need one.
Cross: Mandatory? (no) · What does REFRESH_PAGE do and when do you need it? · What is cq:dropTargets?
Q10. What is cq:template inside a component?
cq:template inside a component holds default property values that are copied onto the new content node when an author first adds the component to a page. For example, it can pre-fill a heading level or a placeholder title. It is stored as _cq_template in the component folder and is completely unrelated to page templates.
Cross: How is that different from a page template? (completely different thing) · Where does it live? (_cq_template in the component folder)
4.2 Intermediate
Q11. What is a proxy component and why do we use it?
A proxy component is a small component in your project that inherits from a Core Component through sling:resourceSuperType, and content refers to the proxy instead of Adobe's path. There are three reasons to use one. You can selectively override parts, such as the HTL or the dialog. You control which version of the Core Component is used, because upgrading means changing one pointer. And your content is decoupled from Adobe's internal paths, so an upgrade never requires rewriting content.
Cross: What's actually in the file? · How do you upgrade v1 to v2? · What if you skip the proxy?
Q12. How do you add one field to an inherited dialog without copying it?
Use the Sling Resource Merger. Create a dialog in your component that contains only the new field, and position it with sling:orderBefore; everything else is inherited from the parent dialog. You can remove an inherited field with sling:hideResource. This is better than copying the dialog, because a copy stops receiving Adobe's improvements and has to be maintained by hand.
Cross: Which mount path? (/mnt/overlay) · How do you reorder? · Why not just copy the dialog? (you stop receiving Adobe's improvements)
Q13. What is the difference between a simple and a composite multifield?
A simple multifield repeats a single field, and its values are stored as one multi-value property on the component node — for example, a list of tags. A composite multifield repeats a group of fields, such as a question and an answer, and stores each entry as a child node named item0, item1 and so on. It needs composite="{Boolean}true"; without it, only the first field of each entry is saved. In a Sling Model you read the first with @ValueMapValue as an array or list, and the second with @ChildResource.
Cross: How do you read each in a model? · What are the child nodes called? (item0, item1, …) · What breaks without composite=true? (only the first field saves)
Q14. What is the difference between extending and overlaying a component?
Extending means creating a new component that inherits from another through sling:resourceSuperType; the change affects only your new component. Overlaying means placing a file under /apps at the same relative path as one in /libs, which replaces the product version everywhere. Extend by default, and overlay only when you must customise AEM's own authoring interface, because every overlay has to be checked on each upgrade.
Cross: When would you ever overlay? (adding a tab to page properties) · Which is safer on upgrade? · How does the Resource Merger relate to each?
Q15. Where does component logic go?
Component logic belongs in a Sling Model in the core bundle, not in the HTL. HTL is deliberately restricted — it cannot run arbitrary Java — which keeps templates simple and secure. The HTL calls the model with data-sly-use, and the model contains the logic in a plain Java class that can be unit tested with AEM Mocks.
Cross: Why can't you write logic in HTL? (by design, for security and testability) · What came before Sling Models? (WCMUsePojo, then JSP) · How do you unit test it?
Q16. Why do extra div elements appear around my component?
They are decoration tags, which AEM adds around each component so the editor can select it and so the Style System has an element to apply classes to. You can change the element with cq:htmlTag or decorationTagName — for example to li inside a list — or remove it with cq:noDecoration or decoration=false. Removing it entirely makes the component harder to select in the editor, so it is rarely the right choice.
Cross: Why not always disable it? (harder to select in the editor) · When would you change the tag? (inside a
)
Q17. How do you make a component available on a page?
Two layers must both allow it. First, the component needs a visible componentGroup, so it is not hidden. Second, the template's policy on the container where the author wants to place it must list the component, or its group, as allowed. The policy is the deciding layer: if it does not allow the component, authors never see it, regardless of anything in the component itself.
Cross: Where are policies stored? (under /conf//settings/wcm/policies) · Can two templates allow different components? (yes — each has its own policies) · What controls which templates can be used under a page? (cq:allowedTemplates)
Q18. What is cq:isContainer?
cq:isContainer marks a component as one that can hold other components, so the editor treats it as a drop zone. Examples are the Layout Container, Tabs and Accordion. What authors can place inside it is controlled by a policy on the container, and the container renders its children with data-sly-resource over its child resources.
Cross: Example? (Layout Container, tabs, accordion) · How do you restrict what goes inside? (a policy on the container) · How do children render? (data-sly-resource over child resources)
Q19. How does in-place editing work?
In-place editing lets authors edit text directly on the page instead of in a dialog. It is configured in cq:editConfig under cq:inplaceEditing, with an editor type such as text for rich text, or title or plaintext for simple text. The value is saved through the same Sling POST Servlet as a dialog. You might disable it for rich text that must follow strict formatting rules.
Cross: How does the value save? (same Sling Post Servlet) · Why might you disable it? (rich text with strict formatting rules)
Q20. How do you version a component?
Follow the Core Components convention: each version lives in its own folder, such as v1 and v2, and the proxy component points at the version you want. Existing content refers to the proxy, so it keeps working. You never change a released version in place, because existing content depends on its markup and properties; a breaking change becomes a new version, with a content migration if pages should move to it.
Cross: Why not edit in place? (you'd break existing content) · How do you migrate content between versions?
4.3 Advanced
Q21. How would you build a listing component whose items come from the page tree rather than being hand-authored?
Work through four decisions.
Traverse or query. If the items live under one known parent page, traverse its children: it needs no index and is predictable. If they can be anywhere and are selected by tag, you must query, which means confirming an Oak index exists and always setting a limit.
Where the work happens. Build the list once in @PostConstruct, not in a getter that HTL might call repeatedly inside a loop.
How much renders on the server. For a long list, render the first batch on the server so the page is fast and crawlable, and load the rest through a servlet. Keep that servlet cacheable by registering it by resource type with a selector and passing the batch number as a suffix, not a query parameter.
Invalidation. A derived listing goes stale when a child page is published, so add a flush rule that invalidates the listing page when its children are activated.
Cross: Query or traverse — how do you decide? · Is your query indexed and how do you know? · Why a suffix and not a query parameter? · What happens when an author adds a new product?
Q22. A component works on author but is blank on publish. How do you debug it?
Work through these checks in order:
Read error.log on publish first. A model using the optional injection strategy renders nothing instead of throwing, so the real error may only appear in the log.
Confirm that everything the component references was activated — assets, fragments and, for listings, the child pages themselves.
Check that the anonymous user can read every path the component reads.
Check that the component's clientlib is loading, including allowProxy on the clientlib folder.
Check whether the model depends on something that only exists on author.
Cross: Why does OPTIONAL hide the problem? · How do you test as anonymous? (private window, direct publish URL) · What does the dispatcher have to do with it?
Q23. How do you make a component accessible?
Use semantic HTML rather than generic div elements, correct heading levels, and alt text supplied through a dialog field. Add ARIA attributes only where native semantics are not enough, make every interactive element usable with the keyboard, and ensure sufficient colour contrast. Extending Core Components also helps, because they are built to WCAG standards, so you inherit that work. Test with tools such as axe and Lighthouse, and by navigating with the keyboard alone.
Cross: How do you test? (axe, Lighthouse, keyboard-only) · Whose job is alt text? (developer provides the field, author fills it, component handles it being empty) · What is a decorative image?
Q24. How do you handle a component that must render differently on mobile?
Prefer CSS and responsive design. If the markup genuinely has to differ, use the Style System or an author-controlled option — never server-side user-agent detection. Device detection on the server breaks dispatcher caching: either every device variant needs its own cache entry, or one variant is cached and served to everyone.
Cross: Why is server-side device detection dangerous with a cache? · What is the Style System? · How do responsive images work in AEM?
Q25. How do you unit test a component?
Test the Sling Model, because that is where the logic lives; HTL itself is not practical to unit test. Use JUnit 5 with AEM Mocks: load test content from a JSON file into AemContext, register the model and any services it needs, adapt a resource to the model, and assert on its output. Services the model depends on can be replaced with mocks using context.registerService.
Cross: What is AemContext? · How do you mock an injected OSGi service? (context.registerService) · What does Cloud Manager's quality gate check?
Q26. How would you let authors change a component's appearance without new components?
Use the Style System. Style options are defined in the component's policy, the author picks one from the component's toolbar, and AEM adds the corresponding CSS class to the component's wrapper element. No code change or new component is needed, and the available styles are controlled centrally per template rather than per component instance.
Cross: Where are styles defined? (in the policy) · How is the class applied? (on the decoration wrapper) · Why better than a dialog dropdown? (controlled centrally per template, no code)
Q27. What is the Sling Model Exporter and why does it matter here?
The Sling Model Exporter serialises a Sling Model to JSON, available at .model.json, by adding @Exporter(name = "jackson", extensions = "json") to the model. It is how a component's authored content reaches a single-page application or a mobile app. Unlike Content Fragments delivered through GraphQL, it follows the structure of the page and its components.
Cross: Which selector and extension does it use? (model and json) · How does it relate to the SPA Editor? (the SPA Editor renders pages from this JSON) · How does it differ from Content Fragments with GraphQL? (page-shaped output versus structured content)
Q28. How do you handle validation in a dialog?
Granite UI provides basic validation on fields, such as required="{Boolean}true". For anything more, register a custom validator in JavaScript with the Granite UI validation registry, targeting a field by a validation attribute or a selector. The script is placed in a clientlib with the category cq.authoring.dialog so it loads only in the editor. A custom validator can also compare two fields.
Cross: Where does the JavaScript live? (a clientlib with the category cq.authoring.dialog) · How do you show the error message? (return it from the validator function) · Can you validate across two fields? (yes, with a custom validator)
4.4 Scenario based
Q29. An author saves the dialog, but nothing appears on the page. What do you check?
Check, in order: a field name missing its ./ prefix; the model reading a property with a different name; an optional injection hiding a failure; and the page being served from the dispatcher cache. Then open the node in CRXDE Lite. That single check splits the problem cleanly: if the property is missing, the dialog did not save it; if it is there, the component is not reading it.
Q30. A component works on one template but not on another. Why?
Almost certainly because of the template's policy. Each template has its own policies, and on the second template the container's policy does not list this component, or its group, as allowed. Check the policy for that container in the template editor rather than changing the component.
Q31. After upgrading Core Components, some pages broke. What went wrong?
The content referred to Adobe's resource type directly instead of a proxy component, so it was tied to a specific version, and the upgrade changed the markup or property names. The immediate fix is to roll back the version or migrate the affected content. The long-term fix is to introduce proxy components and point the content at them, so future upgrades are a deliberate change to one pointer.
Q32. Authors say a component is confusing to use. How do you improve it?
Improve the dialog rather than dismiss the feedback. Add help text with fieldDescription, group related fields into clear tabs, show examples with emptyText placeholders, provide sensible defaults through cq:template, and mark genuinely required fields as required, so authors get immediate feedback instead of a blank component on the page.
Q33. The same component must look different for three product lines. How do you build it?
Use the Style System with different style definitions in each template's policy: one component, three policies. Do not build three separate components, which triples the maintenance, and do not add a "brand" dropdown to the dialog, which spreads a styling decision into every content instance.
Q34. A component has made the page very slow. How do you fix it?
Measure first, then fix the cause. The usual culprits are a synchronous external API call during rendering, a query without an index, or an expensive getter called repeatedly from an HTL loop. The fixes are, respectively, to cache the external data or load it client-side, to add an index, and to do the work once in @PostConstruct.
4.5 Production support
Q35. A component appears in the component browser, but authors cannot drop it onto the page. Why?
Either the policy of the container they are dropping into does not allow that component, or the target area is not actually a container. Check the container's policy in the template editor; the component browser shows every component allowed anywhere on the page, not only in a specific container.
Q36. A component renders, but without any styling. What do you check?
Check that the component's clientlib is actually loaded on the page. The usual causes are that the clientlib's category is not included by the page or template, that allowProxy is not set to true on the clientlib folder (so the browser cannot fetch it from /etc.clientlibs on publish), or a typo in the category name.
Q37. A component still shows old content after an author edits it. Why?
On publish, the page is probably being served from the dispatcher cache and has not been invalidated. In the editor, the component may need REFRESH_PAGE in its cq:editConfig listeners, because by default the editor refreshes only the component itself and can show a stale fragment when the change affects other parts of the page.
Q38. A component's dialog will not open. Where do you start?
Open the browser console first. The usual causes are malformed dialog XML, a field with an incorrect sling:resourceType, or a JavaScript error in a custom validator clientlib that stops the dialog from initialising.
Q39. A component works locally but not on the server. What do you check?
Check that the package's filter.xml actually includes the component's path, so it was deployed at all, and that the Java bundle deployed and is Active in the Felix console. A path missing from the filter is easy to overlook, because it works locally where the files were created by hand or by an earlier build.
Cross Questions — how this topic gets drilled
Thread A — from "tell me about a component you built" What did the dialog look like? → Multifield? → Simple or composite? → How did you read it in the model? → What node structure resulted? → What if the author adds fifty rows? → How did you validate? → What happened when a field was empty?
Thread B — from "do you use Core Components?" Which ones? → Directly or proxied? → What is a proxy? → What's in the file? → How do you override just the markup? → How do you add one dialog field? → What is the Resource Merger? → How do you hide an inherited field? → How would you upgrade v1 to v2?
Thread C — from "how does a dialog save data?" What handles the POST? → What is the Sling Post Servlet? → What does ./ mean? → What happens without it? → What is @TypeHint? → How do you delete a property? → How does a composite multifield become child nodes?
Thread D — from "how do you decide to build a custom component?" Why not Core Components? → What can the Style System do instead? → Where are styles defined? → What if the markup must differ, not just CSS? → Extend or overlay? → Which is safer on upgrade?
Best Interview Answers
6.1 "Walk me through how you build a component" — about 2 minutes
"I'd start by asking whether I should build one at all. My first check is whether a Core Component already does most of it, because extending one gives me accessibility and Adobe's ongoing maintenance for free.
Assuming I do need one, I create a folder under /apps//components/. The .content.xml marks it as a cq:Component with a title, description and componentGroup — and if it's a helper component authors shouldn't place directly, I set the group to .hidden.
If I'm extending a Core Component, that same file gets sling:resourceSuperType, and often that's the entire component — a proxy with no other files, inheriting everything.
The rendering script is an HTL file named after the folder, because Sling's script resolution looks for a script matching the last segment of the resource type. The HTL stays presentation-only; logic goes into a Sling Model in the core bundle that HTL calls with data-sly-use, which keeps it unit-testable.
The dialog goes in _cq_dialog as Granite UI resource types — containers and items nested down to the fields. The detail I'm careful about is the ./ prefix on every field name, because without it the Sling Post Servlet writes the value to the parent node and the component silently reads nothing.
Then optionally _cq_editConfig — I'll add REFRESH_PAGE if editing this component changes something outside itself — and _cq_template for sensible defaults.
Finally a unit test on the Sling Model with AEM Mocks, because Cloud Manager's quality gate checks coverage on new code."
6.2 The graded answer — simple, medium, complex
Interviewers frequently ask for a component story twice: "give me a simple one first", then "now the most complex thing you've built." Here are all three, at the right length.
Simple — about 1 minute
"The simplest one I own is our CTA component. It's a single link that renders either as a button or as an arrow link — the pattern you see all over the site as 'Read the Press Release' or 'Explore more' with a chevron.
Three dialog fields: link text, a pathfield for the target, and a checkbox for opening in a new tab. Plus a style dropdown for button versus arrow link.
It sounds trivial but it has three details I'd call out. The link can point at an internal page or a PDF in our document publishing system, so the model resolves internal paths through resourceResolver.map() to get the short URL, and leaves external ones alone. Second, when the author ticks 'open in new tab' the model emits rel="noopener noreferrer" alongside target="_blank", because without it the opened page can manipulate window.opener. Third, the checkbox needed an uncheckedValue, because otherwise unticking it deletes the property rather than setting it false, which isn't the same thing to the model.
The value of building it was consistency — it replaced about six hand-coded link variants across the site with one component the design system controls."
Medium — about 2 minutes
"A step up is our FAQ accordion. Product category pages have FAQ sections — things like 'What is a transformer in electricity?' — and they're written by the content team primarily for SEO, because industrial buyers search technical questions.
The dialog is a composite multifield: each row has a question as a textfield and an answer as rich text. Composite matters here — with composite=true and the field node as a container, each row becomes a child node like faqs/item0 with both properties on it, rather than a flat string array. In the model I read those with @ChildResource into a list of a small nested model class.
Two things made it more interesting than it looks. First, accessibility — an accordion has a real ARIA contract. Each header is a button with aria-expanded and aria-controls, each panel has an id and role="region" with aria-labelledby back to the button. That means generating stable unique IDs, and they have to stay unique when there are two accordions on one page, so I derived them from the component's own path rather than a counter.
Second, SEO — since these exist for search, I emit FAQPage structured data as JSON-LD alongside the markup, so the questions can appear as rich results. That needed the answer's rich text stripped of HTML for the structured data while keeping it for display.
The result was that FAQ content started appearing as expandable results in search, which was the whole point of the requirement."
Complex — about 3 minutes. This is section 7's flagship story; see below.
6.3 "Tell me about a complex component you developed recently" — the full answer
The structure that works: requirement → what made it complex → approach → the hard part → outcome.
The critical move is "what made it complex." Do not describe a component with lots of fields — that is tedious, not complex. Name a genuine technical challenge: an integration, a caching conflict, a performance problem, or an authoring constraint fighting the technical design.
Here it is delivered in full:
"The most involved one is our product category listing — the component behind the main Products and Solutions landing page.
The requirement looked simple: show product categories as cards, grouped into three tabs — products and systems, software, and solutions — with a Load More button because there are more than twenty categories in some tabs.
What made it complex was that the cards can't be hand-authored. We have hundreds of product category pages and the portfolio changes constantly. If an author had to add a card manually every time marketing launched a category, it would be permanently out of date. So the cards are derived from the page tree — each card is a child page under a configurable root, with its title, description and thumbnail read from that page's properties, filtered by tag.
That created three problems that fought each other.
First, the query. My initial version ran a repository-wide QueryBuilder query filtered by tag. It worked locally on a small content set and was slow on stage. Checking the Query Performance tool showed it wasn't hitting an index cleanly and Oak was traversing. I ended up restricting the search to a known path so the traversal was bounded, added a proper limit, and where a genuine tag query was needed we deployed a custom index. The lesson I'd give anyone is that a query that works on a laptop tells you nothing.
Second, the Load More. The obvious implementation fetches the next batch with a query parameter — something like ?offset=20. But query parameters aren't part of the URL path, so the dispatcher won't cache those responses, and every Load More click would go through to publish. Instead I registered a servlet by resource type with a cards selector and passed the batch number as a suffix — so the URL is a stable path that the dispatcher caches like any other file. Same data, but cacheable.
Third, invalidation. Because the listing is derived, publishing a new product page doesn't tell the listing page's cache anything — the listing stays stale until something else flushes it. We handled that with a flush rule so activating a child page invalidates the parent listing.
The hardest part was actually the first batch. For SEO and first paint, the initial set of cards has to be in the server-rendered HTML — a crawler won't click Load More. But the later batches come from the servlet. So the same card markup had to render two ways, from HTL server-side and from the JSON response client-side, and they had to be identical or the grid visibly broke at the boundary. I solved it by having the servlet return the rendered HTML fragment rather than raw JSON, so there was exactly one template for a card.
The outcome was that the listing page stayed fully dispatcher-cacheable including the Load More responses, the page stopped going stale when the portfolio changed, and the content team stopped raising tickets to add cards — the page just reflects whatever they publish."
Why this answer works. It names a specific, believable requirement. It identifies genuine trade-offs rather than listing features. It shows architectural judgment — the suffix-versus-query-parameter decision is exactly the thinking file 01 taught. It includes a mistake the candidate learned from, which reads as honest. And every sentence invites a follow-up you can answer.
Real Project Examples
Three stories, one per level, all from the same coherent project. Pick the ones you can defend and make them yours.
Story 1 (Simple) — the CTA component
Requirement. Product and news pages needed consistent call-to-action links — "Read the Press Release", "Read the Customer Success Story", "Explore more" — some rendered as buttons, some as arrow links.
Why it was worth building. Before it existed, these were hand-coded inside other components and inside rich text, so there were about six visual variants and none of them matched the design system exactly. Accessibility was inconsistent too — some were
elements with click handlers rather than real links, so keyboard users couldn't reach them.
Approach. One component, four dialog fields: link text, target path, open-in-new-tab, and a style dropdown for button versus arrow. The model resolves internal paths through resourceResolver.map() so links render as short URLs rather than /content/... paths, and leaves external URLs untouched. Links to the document publishing system open in a new tab by default and get a file-type indicator.
Keyboard focusable and activatable for free
aria-expanded Tells a screen reader whether the panel is open
aria-controls → panel id Links the button to what it controls
role="region" on the panel Makes it a navigable landmark
aria-labelledby → button id Names the region using the question
hidden on collapsed panels Removes them from the accessibility tree, not just visually
aria-hidden on the indicator Stops "plus sign" being announced
On context='unsafe' for the JSON-LD. This is the one place you deliberately disable HTL's escaping, because JSON-LD must not be HTML-escaped or search engines cannot parse it. It is safe only because the JSON was built by Jackson, which escapes its own values — not because the content is trusted. If an interviewer asks "isn't unsafe an XSS risk?", that is exactly the answer: it is safe here because a serialiser produced the string, and it would not be safe if I had concatenated it by hand.
8C. COMPLEX — tabbed listing with cacheable Load More
The full component is large, so here are the parts that carry the interview.
8C.1 The model — derived cards, bounded traversal
package com.energy.core.models;
@Model(
adaptables = Resource.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
public class CategoryListingModel {
/** Hard ceiling. Even if an author misconfigures the root, we never * build an unbounded list -- that is what takes an instance down. */private static final int MAX_TOTAL = 200;private static final int DEFAULT_BATCH = 12;@ValueMapValueprivate String rootPath;@ValueMapValueprivate String[] filterTags;@ValueMapValueprivate Integer batchSize;@SlingObjectprivate Resource resource;@OSGiServiceprivate PageManager pageManager;private List<CardModel> firstBatch;private int totalCount;@PostConstructprotected void init() { List<CardModel> all = collectCards(); this.totalCount = all.size(); int size = (batchSize == null || batchSize < 1) ? DEFAULT_BATCH : batchSize; this.firstBatch = all.subList(0, Math.min(size, all.size()));}/** * Walks the children of a KNOWN root instead of running a repository-wide * query. * * Why: a bounded traversal under one parent needs no Oak index and its cost * is predictable. A repo-wide tag query needs a custom index, and without * one Oak traverses everything and logs a traversal warning -- which is * exactly what bit us on stage. */private List<CardModel> collectCards() { if (StringUtils.isBlank(rootPath)) { return Collections.emptyList(); } Page root = pageManager.getPage(rootPath); if (root == null) { return Collections.emptyList(); } List<CardModel> cards = new ArrayList<>(); Iterator<Page> children = root.listChildren(); while (children.hasNext() && cards.size() < MAX_TOTAL) { Page child = children.next(); if (child.isHideInNav() || !matchesTags(child)) { continue; } Resource contentResource = child.getContentResource(); if (contentResource != null) { CardModel card = contentResource.adaptTo(CardModel.class); // adaptTo can return null -- always guard if (card != null && card.isValid()) { cards.add(card); } } } return cards;}private boolean matchesTags(Page page) { if (filterTags == null || filterTags.length == 0) { return true; } // ... tag comparison against the page's cq:tags return true;}public List<CardModel> getCards() { return firstBatch;}public boolean isHasMore() { return totalCount > firstBatch.size();}/** The path the Load More button calls -- see the servlet below. */public String getLoadMoreUrl() { return resource.getPath() + ".cards.html";}public int getTotalCount() { return totalCount;}
}Copy
Copy
8C.2 The servlet — why a selector and a suffix, not a query parameter
package com.energy.core.servlets;
Serves additional card batches for the listing component.
Registered BY RESOURCE TYPE with a selector, and takes the batch number as a
SUFFIX rather than a query parameter.
Why it matters: the dispatcher builds its cache filename from the URL PATH.
A selector and a suffix are part of the path, so
/content/.../listing.cards.html/2
is cached as a file like any page. A query parameter is not part of the
path, so
/content/.../listing.cards.html?batch=2
would go through to publish on every single Load More click.
Same data. One is cacheable, the other is not.
*/
@Component(service = Servlet.class)
@SlingServletResourceTypes(
resourceTypes = "energy/components/categorylisting",
selectors = "cards",
extensions = "html",
methods = "GET"
)
public class CategoryCardsServlet extends SlingSafeMethodsServlet {
int batch = parseBatch(request.getRequestPathInfo().getSuffix()); response.setContentType("text/html"); response.setCharacterEncoding("UTF-8"); // Returns a RENDERED HTML FRAGMENT, not raw JSON. // // Why: the first batch is server-rendered by HTL for SEO, and later // batches come from here. Two separate templates for the same card // drifted apart and the grid visibly broke at the batch boundary. // Returning HTML means there is exactly ONE card template. request.getRequestDispatcher( request.getResource(), buildOptions(batch) ).include(request, response);
}
private int parseBatch(String suffix) {
if (suffix == null) {
return 1;
}
try {
int value = Integer.parseInt(suffix.replace("/", "").trim());
// Never trust a URL value -- clamp it
return Math.max(1, Math.min(value, MAX_BATCH));
} catch (NumberFormatException e) {
return 1;
}
}
private org.apache.sling.api.request.RequestDispatcherOptions buildOptions(int batch) {
org.apache.sling.api.request.RequestDispatcherOptions options =
new org.apache.sling.api.request.RequestDispatcherOptions();
options.setReplaceSelectors("cardsfragment." + batch);
return options;
}
}Copy
Copy
The three things an interviewer will pick up on here:
SlingSafeMethodsServlet because this is read-only — it only handles GET and HEAD. Extending SlingAllMethodsServlet when you only serve GET is a code-review comment, because you are advertising POST/PUT/DELETE you never implemented. (Full detail in file 07.)
Registered by resource type, not by path. A path-bound servlet needs whitelisting in the dispatcher and bypasses ACLs on the resource. Resource-type binding means the URL is a real content path, so permissions apply naturally.
The batch number is clamped. parseBatch never trusts the suffix — an unbounded value from a URL is how someone makes your server build a 10,000-item list.
8C.3 The Load More markup
<div class="cmp-listing__grid" data-listing-grid> <sly data-sly-list.card="${listing.cards}" data-sly-resource="${card.path @ resourceType='energy/components/card'}"/></div><button class="cmp-listing__more" type="button" data-sly-test="${listing.hasMore}" data-load-more> Load more</button><!-- Announces new results to screen readers when the grid updates --><div class="sr-only" role="status" aria-live="polite" data-listing-status></div>
Copy
Copy
The aria-live region is the detail worth mentioning. When Load More injects cards, a sighted user sees them appear. A screen reader user gets nothing unless you announce it. One role="status" element that you update with "12 more results loaded" makes the whole pattern accessible, and almost nobody thinks of it.
8C.4 Unit test — testing what actually breaks
package com.energy.core.models;
@ExtendWith(AemContextExtension.class)
class FaqModelTest {
private final AemContext context = new AemContext();@BeforeEachvoid setUp() { context.load().json("/faq/content.json", "/content"); context.addModelsForClasses(FaqModel.class, FaqItemModel.class);}@Testvoid returnsEmptyListWhenNoQuestionsAuthored() { FaqModel model = adapt("/content/faq-empty"); assertNotNull(model); assertTrue(model.getFaqs().isEmpty(), "must return empty list, never null"); assertFalse(model.isReady());}@Testvoid skipsRowsWhereAuthorLeftTheAnswerBlank() { // Authors WILL do this. It is the most common real-world case. FaqModel model = adapt("/content/faq-partial"); assertEquals(1, model.getFaqs().size(), "rows with a blank answer must be filtered out");}@Testvoid generatesUniqueIdsAcrossTwoAccordionsOnOnePage() { // The bug that a counter-based ID would cause. FaqModel first = adapt("/content/page/first-faq"); FaqModel second = adapt("/content/page/second-faq"); String firstId = first.getFaqs().get(0).getPanelId(); String secondId = second.getFaqs().get(0).getPanelId(); assertNotEquals(firstId, secondId, "two accordions on one page must not emit duplicate IDs");}@Testvoid structuredDataIsOmittedWhenDisabled() { FaqModel model = adapt("/content/faq-no-seo"); assertNull(model.getStructuredData());}private FaqModel adapt(String path) { Resource resource = context.resourceResolver().getResource(path); assertNotNull(resource, "test content missing at " + path); return resource.adaptTo(FaqModel.class);}
}Copy
Copy
Note what is being tested. Not "does the getter return the field" — that tests nothing. These cover the cases authors actually create: a blank component, a half-filled row, two accordions on one page. Those are the paths that break in production, and testing them is what makes a test worth writing. Say that in an interview and it separates you immediately.
Common Mistakes
The mistake What actually happens The fix
Missing ./ in a dialog field name Value written to the parent node; component silently reads nothing Always name="./propertyName"
Forgetting composite="{Boolean}true" Only the first field in each row saves Add it, and make the field node a container
required="true" without {Boolean} Stored as a string; some fields ignore it Use {Boolean}true
Checkbox with no uncheckedValue Unticking deletes the property rather than setting false Set both value and uncheckedValue
HTL file not named after the folder Sling never finds the script Name it exactly after the folder
Using a Core Component directly Adobe's resource type baked into content; upgrades become migrations Always create a proxy
Overlaying /libs instead of extending Changes behaviour globally; breaks on upgrade Use sling:resourceSuperType
Business logic in HTL Untestable, and HTL can't express it well anyway Sling Model
Expensive work in a getter Called repeatedly from HTL loops @PostConstruct, store in a field
Returning null from a getter HTL renders "null", or you need guards everywhere Empty string / empty list
Counter-based IDs in a repeating component Duplicate IDs when two instances are on one page Derive from the resource path
Unbounded listChildren() Builds a huge list; can take the instance down Hard ceiling constant, always
Trusting a value from a suffix or parameter Someone requests batch 99999 Parse and clamp
Query parameters for pagination Dispatcher won't cache; every click hits publish Selector plus suffix
No data-sly-test guard Unconfigured components render empty divs that break layouts Guard the outer element
Empty componentGroup by accident Component never appears in the browser Set a real group, or .hidden deliberately
Building a component for a styling variant Component sprawl and duplicated maintenance Style System
No fieldDescription Authors misuse the component and raise tickets Help text on every non-obvious field
Best Practices
On deciding what to build. Default to Core Components. Extend through a proxy. Reach for the Style System before building a variant. Build fully custom only when nothing fits — and expect to justify it.
On structure. One component, one job. Fifteen dialog fields across six tabs usually means two components. Keep the folder self-contained.
On the dialog. Every field gets a fieldLabel and, unless obvious, a fieldDescription. Group into tabs past roughly six fields. Mark genuinely required fields required. Restrict pathfields with rootPath so authors cannot pick something nonsensical.
On the model. Adapt from Resource unless you genuinely need request-scoped data — it is easier to test and reusable outside a request. Use OPTIONAL injection. Never return null. Derived work in @PostConstruct. Always put a hard ceiling on anything that builds a list.
On HTL. Presentation only. Always guard the outer element with data-sly-test. Correct escaping context every time — the wrong context is as dangerous as none. Add an editor placeholder for the empty state.
On accessibility. Semantic elements, correct heading levels, an alt-text field on every image, keyboard support on anything interactive, and an aria-live region for anything that updates content dynamically. Inheriting from Core Components gives you a large head start.
On performance. Lazy-load below-the-fold images. Never block on an external call during render. Think about whether a component clientlib belongs in a shared bundle instead.
Debugging Tips
Start by splitting the problem in half. Almost every component bug is either "the data isn't there" or "the data is there but isn't rendering." Open CRXDE and look at the component's node. Property missing → dialog problem. Property present → model or HTL problem. That one check saves enormous time and is a genuinely good thing to describe in an interview.
When the dialog isn't saving: check the node in CRXDE — did the property land on the parent? That is a missing ./. Watch the POST in the browser network tab to see exactly what field names are sent. Check the console for JS errors preventing submission.
When the dialog won't open: browser console first. Malformed dialog XML, a wrong sling:resourceType on a field, or a broken custom validator clientlib.
When the component renders nothing: turn on the Developer layer in the page editor — it shows the resource type and resolved script, which immediately tells you whether Sling even found your HTL. Check /system/console/status-adapters for your model's adapter. Output ${model} temporarily — an object reference means it resolved, nothing means the adaptation returned null.
When it works on author but not publish: was everything it references activated; test as anonymous in a private window against the publish URL directly, which separates permissions from caching; check allowProxy on the clientlib.
Console What it tells you
Developer layer in the editor Resource type and resolved script per component
/system/console/status-adapters Whether your Sling Model adapter registered
/system/console/servletresolver What a URL actually resolves to
/crx/de Whether the property is on the node, and where
Browser network tab Exactly what the dialog POSTed
12. Performance Optimization
In the model. Work once in @PostConstruct. A getter called from inside data-sly-list runs every iteration — a repository lookup inside a fifty-item loop is fifty lookups.
In listings. Prefer walking a known path over a repository-wide query. If you must query, confirm it is indexed and always set a limit. Always put a hard ceiling in code as well, because a misconfigured root path should degrade, not kill the instance.
In external calls. Never synchronously during render without a timeout. Cache in the OSGi service, or move the fetch client-side.
In images. Serve renditions, never the original. loading="lazy" below the fold.
In clientlibs. A component clientlib shipping on every page is worse than putting the CSS in the site bundle. Consider how many pages actually use the component.
In caching. The architectural one. A component rendering per-visitor data server-side makes the whole page uncacheable. Isolate personalisation — client-side fetch, or Sling Dynamic Include — so the rest stays cached. And when you do fetch client-side, use a selector and suffix so that response is cacheable too.
Real Production Scenarios
Dialog saves but nothing renders. Check the node in CRXDE. Property on the parent → missing ./. Property present → model or HTL.
Only the first field of each multifield row saves. composite="{Boolean}true" missing, or the field node isn't a container.
Component missing from the browser. Empty or .hidden componentGroup, or the template policy doesn't allow it.
Visible but can't be dropped in. The container's policy doesn't allow it, or the target isn't a container.
Renders unstyled. Clientlib not loaded, allowProxy not true, or a category typo.
Extra divs breaking the layout. Decoration wrapper. Change it with decorationTagName, or decoration=false accepting harder selection in the editor.
Editor shows stale content after an edit. Add afteredit="REFRESH_PAGE" if the edit affects anything outside the component.
Core Components upgrade broke pages. Content references Adobe's resource type directly. Migrate content and introduce proxies.
Component throws when a field is empty. Injection strategy is REQUIRED, or a getter doesn't null-check.
Page slow after adding one component. Profile. Usually a blocking external call, an unindexed query, or an expensive getter in a loop.
Unticking a checkbox doesn't disable the feature. No uncheckedValue, so the property is deleted rather than set false — and a missing property may not equal false in your logic.
Two accordions on one page, and clicking one opens the other. Duplicate IDs from counter-based generation. Derive IDs from the resource path.
Load More button hammers publish. Pagination passed as a query parameter, so the dispatcher never caches it. Move to a selector plus suffix.
Derived listing shows stale content after publishing a new page. The listing page's cache doesn't know a child changed. Add a flush rule invalidating the parent on child activation.
Authors can select the wrong kind of asset. The pathfield has no rootPath restriction.
Works locally but not on the server. Check the package filter includes the component path, and that the bundle deployed and is ACTIVE.
Rich text renders escaped HTML tags on the page. Missing @ context='html' on that value.
target="_blank" links flagged in a security review. Missing rel="noopener noreferrer".
Screen reader users can't tell that Load More did anything. No aria-live region announcing the update.
JSON-LD structured data invalid in Search Console. Built by string concatenation and an author's answer contained a quote. Build it with a serialiser.
Follow-up Questions
How many components does your project have?
How many are custom versus Core Component proxies?
Who writes the dialogs — you, or a dedicated author-experience person?
How do you handle a design change affecting twenty components?
What's your test coverage on models?
Have you migrated content between component versions?
What's the most complex dialog you've built?
How do you decide between a dialog field and a policy setting?
What would you do differently if you built that component again?
That last one deserves a genuine answer. For the listing story: "I'd have designed the Load More around a selector and suffix from day one. We built it with a query parameter, shipped it, and then spent a sprint undoing the caching problem we'd created in week one."
Comparison Tables
Core Component (proxied) versus fully custom
Core Component (proxied) Fully custom
Build effort Very low High
Accessibility Built in, WCAG tested Yours to implement
Maintenance Adobe maintains it You maintain it
Upgrades Change the proxy Manual
Flexibility Limited to what it supports Total
When to choose Almost always Only when nothing fits
Extend versus Overlay
Extend Overlay
Mechanism sling:resourceSuperType Same path under /apps as /libs
Scope Just your new component Global
Upgrade risk Low High
Typical use Customising a Core Component Customising the AEM UI itself
Simple versus Composite Multifield
Simple Composite
composite property Not set {Boolean}true
Fields per row One Several
Stored as Multi-value string property Child nodes item0, item1, …
field node type A form field A container
Read with String[] / List @ChildResource List
Dialog field versus Policy setting
Dialog field Policy setting
Who changes it Content author, per instance Template author, once
Example Link text, heading Allowed styles, allowed components
Varies per Component instance Template
Rule of thumb Content varies → dialog Configuration is consistent → policy
The three difficulty levels, side by side
Simple (CTA) Medium (FAQ) Complex (Listing)
Dialog 4 plain fields Composite multifield Multifield + tag picker + config
Model ~60 lines Parent + nested model Derived data, bounded traversal
Extra pieces None JSON-LD, ARIA contract Servlet, caching strategy, flush rule
Key risk Missing uncheckedValue Duplicate IDs Unindexed query, uncacheable pagination
Answer length ~1 min ~2 min ~3 min
16. Memory Tricks
Component files: "Define, Render, Edit, Default" — .content.xml defines, HTL renders, _cq_dialog edits, _cq_template defaults.
The ./ rule: "No dot-slash, no data."
Proxy pattern: "Point, don't paste."
Multifield: "Composite means children."
Component visibility needs two yeses: "Group and policy — both must say yes."
Naming: "The script wears the folder's name."
Where logic lives: "HTL shows, Java thinks."
Pagination: "A dot is cached, a question mark is not." (from file 01 — selector and suffix cache, query parameters do not)
IDs in repeating components: "Path, not counter."
Revision Notes
A component is a folder under /apps that content points at via sling:resourceType.
Files: .content.xml (cq:Component), .html, _cq_dialog, _cq_editConfig, _cq_template, clientlibs. cq on disk, cq: in CRXDE.
HTL must be named after the folder — Sling script resolution, not a special rule.
componentGroup empty or .hidden → not in the browser. Policy must also allow it.
Proxy pattern: sling:resourceSuperType to a Core Component. Selective override, version control, decoupled content. Always do this.
./ on every dialog field name. Sling Post Servlet handles the save; nobody writes a servlet for dialogs.
Simple multifield → multi-value string property. Composite (composite=true, field is a container) → child nodes item0, item1. Read with @ChildResource.
Checkbox needs value and uncheckedValue.
{Boolean} and {Long} type hints matter.
cq:editConfig: cq:actions, cq:listeners (REFRESH_PAGE), cq:dropTargets, cq:inplaceEditing. Editor only.
Decoration wrapper: decoration=false, cq:noDecoration, decorationTagName.
Add one field to an inherited dialog → Sling Resource Merger, sling:orderBefore; remove with sling:hideResource.
Logic in the Sling Model. @PostConstruct for derived values. Never return null. Hard ceiling on any list.
Derived listings: traverse a known path over a repo-wide query; index and limit if you must query; hard cap in code.
Pagination must use a selector and suffix, never a query parameter, or the dispatcher can't cache it.
IDs in repeating components come from the resource path, not a counter.
Style System for appearance variants — not new components.
Test the model on the empty and partial cases authors actually create.
Cheat Sheet
Component folder
/apps//components//
.content.xml cq:Component + jcr:title + componentGroup [+ sling:resourceSuperType]
.html HTL — must match the folder name
_cq_dialog/.content.xml
_cq_editConfig.xml
_cq_template/.content.xml
clientlibs/Copy
Copy
.content.xml properties
jcr:primaryType="cq:Component" makes it a component
jcr:title name shown to authors
jcr:description tooltip
componentGroup browser group; ".hidden" hides it
sling:resourceSuperType inherit from another component
cq:isContainer="{Boolean}true" can hold child components
cq:noDecoration="{Boolean}true" no wrapper divCopy
Copy
Dialog skeleton
cq/gui/components/authoring/dialog/richtext rich text
cq/gui/components/coral/common/form/tagfield tag pickerCopy
Copy
Field properties
name="./property" ALWAYS the ./ prefix
fieldLabel the label
fieldDescription help text
required="{Boolean}true" mandatory
emptyText placeholder
value / uncheckedValue checkboxes — set BOTH
rootPath restrict a pathfield
composite="{Boolean}true" composite multifield
selected="{Boolean}true" default option in a selectCopy
Copy
Resource Merger
sling:orderBefore="fieldName" position a merged field
sling:hideResource="{Boolean}true" remove an inherited field
sling:hideProperties="[prop1,prop2]" hide inherited propertiesCopy
Copy
cq:editConfig
data-sly-use.model="com.x.Model" bind a Sling Model
data-sly-test="${model.ready}" conditional render
data-sly-list.item="${model.items}" loop
data-sly-element="${model.headingLevel}" dynamic tag name
data-sly-resource="${path @ resourceType='...', decoration=false}"
${value @ context='html'} rich text
${value @ context='uri'} URLs
${value @ context='unsafe'} ONLY for serialiser-produced JSON-LDCopy
Copy
Accessibility contract for an accordion
Copy
Copy
19. Frequently Forgotten Things
./ on every dialog field name. The single most common component bug.
composite="{Boolean}true" — without it a multifield saves only the first field per row.
The field node in a composite multifield must be a container.
Checkboxes need uncheckedValue, or unticking deletes the property.
{Boolean} and {Long} type hints — everything is a string without them.
The HTL file must match the folder name.
_cq_dialog on disk is cq:dialog in CRXDE.
Empty componentGroup hides the component — a feature, not a bug.
Two things must allow a component: its group, and the template policy.
Always proxy Core Components.
cq:template in a component is not a page template.
cq:editConfig affects the editor only.
A getter can be called many times from HTL — do the work in @PostConstruct.
OPTIONAL injection hides typos. A misspelled property name gives null, not an error.
adaptTo() can return null — including contentResource.adaptTo(CardModel.class) inside a loop.
Pagination via query parameter is not cacheable. Selector plus suffix.
Counter-based IDs collide when two instances of a component share a page.
20. Final Interview Summary
1. What a component is. A folder under /apps with everything to render one content type and let an author edit it. Content points at it via sling:resourceType.
The files. Definition, HTL named after the folder, dialog, edit config, defaults, clientlib.
The proxy pattern. Always. Selective override, version control, content decoupled from Adobe's paths.
The dialog. Granite UI containers and items. ./ on every name. Composite multifields make child nodes.
How saving works. The Sling Post Servlet writes form fields as properties. You never write a servlet for a dialog.
Where logic lives. Sling Model, not HTL. @PostConstruct for derived work. Never return null.
Inheritance and merging. sling:resourceSuperType for inheritance; the Resource Merger to add or hide individual dialog fields.
When not to build. Style System for appearance, policies for configuration, Core Components for anything they cover.
Production concerns. Bounded queries, cacheable pagination, accessibility, and the empty state authors will definitely create.
Your three stories. Simple, medium and complex, ready at 1, 2 and 3 minutes. This is the question that will actually be asked.
Mock Interview
How to use this: cover the answers, 25-minute timer, speak every answer out loud.
The interviewer asks:
What is a component in AEM, and how does content connect to it?
What files make up a component and what does each do?
Why must the HTL file be named after the component folder?
What is componentGroup, and why might a component not appear in the browser?
What is a proxy component and why do we use them?
How would you upgrade Core Components from v1 to v2?
How do you add a single field to an inherited dialog without copying it?
What does ./ mean in a dialog field name, and what happens without it?
What handles saving a dialog? Did you write that code?
Simple versus composite multifield — structure and how you read each.
Why do extra divs appear around my component, and how do I control them?
What is cq:editConfig used for? A real example of when you needed it.
Where should component logic live, and why not HTL?
Start simple — tell me about a straightforward component you built.
Now something harder — a component with repeating authored content.
Your component works on author but is blank on publish. Debug it.
How would you build a listing whose items come from the page tree?
How do you unit test a component, and what do you actually test?
How would you let authors change appearance without new components?
Tell me about the most complex component you have built.
Model answers
A folder under /apps holding everything needed to render one type of content and let an author edit it — HTL, dialog, usually a Sling Model. Content connects through sling:resourceType on the content node, pointing at the component's path. That property is the entire link between content and code.
.content.xml marks it a cq:Component with title, description and componentGroup. An HTL file named after the folder renders it. _cq_dialog is the author's form. _cq_editConfig controls editor behaviour. _cq_template supplies defaults when first dropped. Optionally a clientlibs folder. On disk they're cq because a colon isn't a legal filename character; in CRXDE they're cq:.
Sling script resolution — one rule is a script named after the last segment of the resource type. For energy/components/cta that segment is cta, so Sling looks for cta.html. It's the general Sling rule applied here, not a special component rule.
It groups components in the browser. Empty or .hidden means it doesn't appear — which we use deliberately for helper components an author should never place directly. But there's a second reason: the template's policy must allow it too. Both have to say yes.
A component in our project whose entire content is essentially a sling:resourceSuperType pointing at a Core Component. Three reasons. Content stores our resource type, not Adobe's, so we're not coupled to their internal paths. We can override just the HTL or part of the dialog while inheriting everything else. And version upgrades become a one-line change instead of a content migration.
Change sling:resourceSuperType in the proxy from /v1/ to /v2/, then test, because v2 may have different markup or property names. No content changes, because content references the proxy. That's the whole reason the pattern exists — without it, that upgrade means rewriting the resource type on every affected node.
Create a dialog containing only the new field and let the Sling Resource Merger merge it with the inherited one. Position with sling:orderBefore, remove inherited fields with sling:hideResource, hide properties with sling:hideProperties. I never copy Adobe's dialog, because copying means I stop receiving their improvements.
./ means the property is relative to the component's own node. Without it, the Sling Post Servlet writes it to the parent instead. The dialog appears to save with no error, but the model reads nothing. It's the most common component bug, and the fastest confirmation is opening the node in CRXDE and finding the property one level up.
The Sling Post Servlet — Sling's built-in handler for POST to a repository path. Its default behaviour writes the submitted fields as properties on the node at that path. So nobody writes that code and nothing generates it. A dialog is essentially an HTML form posting to the component's node path, which is exactly why ./ addressing matters.
Simple stores a multi-value string property — one value per row, read as a String[]. Composite sets composite="{Boolean}true" and wraps its fields in a container, so each row becomes a child node like faqs/item0 holding several properties, read with @ChildResource into a list of a nested model. The classic mistake is forgetting composite=true, in which case only the first field of each row saves.
The decoration tag — AEM wraps components so the editor can attach its toolbar and click handling. Three controls: decoration=false on an include, cq:noDecoration on the component, or decorationTagName to change the element. I usually reach for decorationTagName, because inside a
a
wrapper is invalid HTML but an
is correct. I avoid removing it entirely where I can, since the component becomes harder to select in the editor.
Editor behaviour — toolbar actions, DAM drop targets, in-place editing, listeners. The real example I've needed is afteredit="REFRESH_PAGE". By default AEM refreshes only the edited component, which is fast, but if that edit changes something outside it the page looks wrong until the author reloads manually. It has no effect on publish at all.
In a Sling Model in the core bundle. HTL is deliberately restricted — it can't express arbitrary Java — and that's a feature, because it forces logic somewhere testable and stops business rules hiding in templates. The model gets unit tested; the HTL just displays what it exposes.
(The simple CTA story from 6.2 — roughly a minute. Four dialog fields, but call out the three real details: resourceResolver.map() for internal links, rel="noopener" with target="_blank", and uncheckedValue on the checkbox.)
(The FAQ accordion story from 6.2 — roughly two minutes. Composite multifield and @ChildResource, the ARIA contract, path-derived IDs so two accordions on a page don't collide, and JSON-LD structured data built with a serialiser rather than string concatenation.)
error.log on publish first, because a model with OPTIONAL injection renders empty rather than throwing — the real error may only be in the log. Then whether everything it references was activated, which for a derived listing includes the child pages themselves. Then anonymous read permissions on every path it reads. Then the clientlib and allowProxy. Then whether the model depends on something author-only. I'd test against the publish URL directly in a private window, which separates a permissions problem from a caching one.
(The Q21 answer: traverse a known root versus query, index and limit if querying, build in @PostConstruct with a hard ceiling, server-render the first batch for SEO, serve later batches from a servlet using a selector and suffix so they stay cacheable, and add a flush rule so publishing a child invalidates the parent listing.)
AEM Mocks with JUnit 5 — load test content from JSON, register the models, adapt a resource, assert. But the important part is what I test: not that a getter returns its field, but the cases authors actually create — no multifield rows, a half-filled row, two instances on one page. Those are the paths that break in production. Any injected OSGi service gets registered as a mock in the context.
The Style System. Style classes are defined in the component's policy, the author picks one from a dropdown, and AEM adds the class to the wrapper. No code, no new resource type. Better than a dialog dropdown because it's controlled centrally per template rather than per instance, and better than variant components because there's nothing extra to maintain.
(The tabbed listing story from 6.3 — three minutes. Requirement, why cards can't be hand-authored, the three conflicting problems — query performance, cacheable Load More, stale invalidation — the hardest part being two rendering paths for one card, and the measurable outcome.)
Next topic
03-Editable-Templates-and-Policies — your syllabus point 3 in full: how editable templates are created, structure versus initial content, what a policy actually is and where it's stored, page policy versus content policy, how policies inherit, and how you allow or hide components on a page.