Vue Component Integration in AEM
How a server-side AEM (HTL) component hands data to a client-side Vue 2.7 component, and how Vue components compose with each other once mounted.
Architecture
HTL renders static HTML (fallback + mount point)
└── data-vue-component + data-props attribute
└── main.js bootstrapper (querySelectorAll '[data-vue-component]')
└── new Vue({ render: h => h(Component, { props }) }).$mount(node)
└── Component renders + nested child SFCs (<Card/>, ...)
Two layers, two different rules:
- HTL (server-side) only produces static HTML. It has no awareness of Vue's component registry, so custom Vue tags like
<my-component> are invalid here.
- Vue SFC (client-side) is compiled by Webpack, so custom Vue tags are valid inside
.vue templates. This is what makes nested components possible (see Using a Vue Component Inside Another).
File map
Step 1 — Sling Model
File: core/src/main/java/com/nhansite/core/models/<Name>Model.java
Rules:
adaptables = Resource.class
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
@ValueMapValue field names must match the JCR property names saved by the dialog (e.g., field imageSrc → dialog name="./imageSrc")
getPropsJson() must return a JSON object with keys matching Vue prop names in the .vue file, not the Java field names
@Model(adaptables = Resource.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class HeroModel {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@ValueMapValue
private String title;
@ValueMapValue
private String imageSrc;
@ValueMapValue
private String[] items;
public String getTitle() { return title; }
public String getImageSrc() { return imageSrc; }
public List<String> getItems() {
return items == null ? Collections.emptyList() : Arrays.asList(items);
}
public String getPropsJson() {
try {
Map<String, Object> props = new HashMap<>();
props.put("title", title == null ? "" : title);
props.put("image", imageSrc == null ? "" : imageSrc); // key = Vue prop name
props.put("items", getItems());
return OBJECT_MAPPER.writeValueAsString(props);
} catch (Exception e) {
return "{}";
}
}
}
Key: getPropsJson() key names must match props: { ... } in the Vue SFC, not the Java field names.
Step 2 — Vue Component
File: ui.frontend/src/main/webpack/components/<Name>.vue
- Declare all props with type and default.
- Prop names must match keys in
getPropsJson().
<template>
<div class="cmp-hero">
<h2 v-if="title">{{ title }}</h2>
<img v-if="image" :src="image" :alt="title" />
<ul v-if="items && items.length">
<li v-for="(item, index) in items" :key="index">{{ item }}</li>
</ul>
</div>
</template>
<script>
export default {
name: "Hero",
props: {
title: { type: String, default: "" },
image: { type: String, default: "" },
items: { type: Array, default: () => [] }
}
};
</script>
Using a Vue Component Inside Another Vue Component
This is the main reason to introduce the SFC layer at all: composition. Once Vue has mounted the top-level component, it can render child components freely — AEM is no longer involved.
Why it works in .vue but not in HTL
Custom Vue tags (<Card />) are compiled away by Webpack + vue-loader into render functions. By the time the browser runs, there are no custom tags left — only the resolved DOM. HTL, by contrast, ships raw HTML to the browser, so a <card> tag there is just an unknown element.
Example: Hero renders a child Card
Child SFC — ui.frontend/src/main/webpack/components/Card.vue:
<template>
<article class="cmp-card">
<h3>{{ heading }}</h3>
<p>{{ body }}</p>
</article>
</template>
<script>
export default {
name: "Card",
props: {
heading: { type: String, default: "" },
body: { type: String, default: "" }
}
};
</script>
Parent SFC — Hero.vue imports and uses it via local registration:
<template>
<div class="cmp-hero">
<h2 v-if="title">{{ title }}</h2>
<Card
v-for="(item, index) in items"
:key="index"
:heading="item.heading"
:body="item.body" />
</div>
</template>
<script>
import Card from "./Card.vue";
export default {
name: "Hero",
components: { Card }, // local registration — preferred
props: {
title: { type: String, default: "" },
items: { type: Array, default: () => [] }
}
};
</script>
Rules for nested components
- Only the parent is a mount point.
Hero is registered in main.js and referenced by data-vue-component="hero". Card is never mounted by AEM — it lives entirely inside Hero's render tree.
- Children get data only through props passed down by the parent. AEM/HTL knows nothing about
Card.
- Do NOT give a nested child its own
data-vue-component. That creates a second, independent Vue instance instead of a parent-child relationship.
- A nested child usually needs no Sling Model and no dialog — its data comes from the parent.
Local vs global registration
Nested SFC child vs a separate AEM component
Step 3 — Register component in main.js
File: ui.frontend/src/main/webpack/main.js
The bootstrapper finds every mount point in the page and instantiates the matching component. Only top-level components go in the registry — nested children are reached through their parent's import, so they must stay out of it.
import Vue from "vue";
import Hero from "./components/Hero.vue";
// Card.vue is intentionally NOT imported here:
// it is a child of Hero and imported inside Hero.vue.
const registry = {
hero: Hero,
// other top-level (authorable) components...
};
function parseProps(el) {
try {
return JSON.parse(el.getAttribute("data-props") || "{}");
} catch (e) {
return {};
}
}
document.querySelectorAll("[data-vue-component]").forEach((el) => {
const Component = registry[el.getAttribute("data-vue-component")];
if (!Component) return;
new Vue({
render: (h) => h(Component, { props: parseProps(el) }),
}).$mount(el);
});
The registry key is the value used in data-vue-component="hero" in HTL.
Step 4 — HTL Component
File: ui.apps/src/main/content/jcr_root/apps/nhansite/components/content/<name>/<name>.html
Rules:
- Use a
<div> (or any block element) with data-vue-component and data-props.
data-props must point to model.propsJson (HTL resolves getPropsJson() via JavaBean convention).
- Always use
@ context='attribute' on data-props.
- Fallback content inside the div renders before Vue mounts and in environments without JS. Include all visible fields.
- For image
src, use @ context='uri' to block javascript: URLs.
<sly data-sly-use.model="com.nhansite.core.models.HeroModel"/>
<div
class="cmp-hero"
data-vue-component="hero"
data-props="${model.propsJson @ context='attribute'}">
<div class="cmp-hero__fallback">
<h2 data-sly-test="${model.title}">${model.title}</h2>
<img data-sly-test="${model.imageSrc}" src="${model.imageSrc @ context='uri'}" alt="${model.title}"/>
<ul data-sly-test="${model.items}" data-sly-list.item="${model.items}">
<li>${item}</li>
</ul>
</div>
</div>
Step 5 — Component Definition
File: ui.apps/src/main/content/jcr_root/apps/nhansite/components/content/<name>/.content.xml
Minimum required attributes for AEM Author to register the component:
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:cq="http://www.day.com/jcr/cq/1.0"
jcr:primaryType="cq:Component"
jcr:title="Hero Component"
jcr:description="Hero with Vue 2.7"
componentGroup="Nhan Site - Content"/>
Step 6 — Author Dialog
File: ui.apps/src/main/content/jcr_root/apps/nhansite/components/content/<name>/_cq_dialog/.content.xml
Rules:
- Dialog field
name="./fieldName" saves to JCR property fieldName.
fieldName must match the @ValueMapValue field name in the Sling Model.
- Use
pathfield for DAM asset paths, textfield for strings, multifield for arrays.
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured"
jcr:title="Hero Component"
sling:resourceType="cq/gui/components/authoring/dialog">
<content jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container">
<items jcr:primaryType="nt:unstructured">
<tabs jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/tabs">
<items jcr:primaryType="nt:unstructured">
<properties jcr:primaryType="nt:unstructured"
jcr:title="Properties"
sling:resourceType="granite/ui/components/coral/foundation/container">
<items jcr:primaryType="nt:unstructured">
<title jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Title"
name="./title"/>
<imageSrc jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
fieldLabel="Image Path"
rootPath="/content/dam"
name="./imageSrc"/>
<items jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
fieldLabel="Items">
<field jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
name="./items"/>
</items>
</items>
</properties>
</items>
</tabs>
</items>
</content>
</jcr:root>
Step 7 — Include Clientlib in Page Template
File: ui.apps/src/main/content/jcr_root/apps/nhansite/components/page/customheaderlibs.html
<sly data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html">
<sly data-sly-call="${clientlib.css @ categories='nhansite.base'}"/>
<sly data-sly-call="${clientlib.css @ categories='nhansite.site'}"/>
</sly>
File: ui.apps/src/main/content/jcr_root/apps/nhansite/components/page/customfooterlibs.html
<sly data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html">
<sly data-sly-call="${clientlib.js @ categories='nhansite.base', async=true}"/>
<sly data-sly-call="${clientlib.js @ categories='nhansite.site'}"/>
</sly>
nhansite.site is the category of clientlib-site which bundles main.js (Vue bootstrapper) and all .vue components. Without this line, Vue never runs and all components render only their HTL fallback.
Data Flow Summary
Author saves dialog
→ JCR stores: { title, imageSrc, items[] }
→ HeroModel reads via @ValueMapValue (field names = JCR property names)
→ getPropsJson() serializes to { "title", "image", "items" } (keys = Vue prop names)
→ HTL writes to data-props attribute (context='attribute' HTML-escapes)
→ Browser parses attribute (auto-decodes HTML entities)
→ main.js: parseProps() reads getAttribute('data-props') → JSON.parse()
→ new Vue({ props }).mount(node) → Hero renders
→ Hero passes props down → nested <Card/> children render (no AEM involved)
Naming Convention Trap
JCR property name (Java field) ≠ Vue prop name.