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/others/vuejs-aem-intergration.md.

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

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

LayerFile
Sling Modelcore/.../models/<Name>Model.java
Vue SFCui.frontend/src/main/webpack/components/<Name>.vue
Bootstrapperui.frontend/src/main/webpack/main.js
HTLui.apps/.../components/content/<name>/<name>.html
Component defui.apps/.../components/content/<name>/.content.xml
Dialogui.apps/.../components/content/<name>/_cq_dialog/.content.xml

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

RegistrationHowUse when
Local (preferred)import Card + components: { Card } in the parentChild is used by one/few parents; keeps the dependency explicit and tree-shakeable
GlobalVue.component("card", Card) in main.jsChild is reused everywhere (e.g., base UI primitives like buttons/icons)

Nested SFC child vs a separate AEM component

ApproachMounted by AEM?Has dialog / Sling Model?Use when
Nested SFC childNo (inside parent's tree)NoPure UI decomposition; data flows from the parent's props
Separate AEM componentYes (own mount point)YesAuthors must add/configure it independently in the page editor

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.

LayerNameExample
JCR / Java fieldimageSrc@ValueMapValue private String imageSrc
Dialog name./imageSrcmust match Java field
getPropsJson() keyimagemust match Vue prop
Vue propimageprops: { image: { type: String } }