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/articles/seo-ld-json-nested.md.

Advanced guide for building nested JSON-LD structured data objects in AEM.

SEO LD+JSON: xây dựng object lồng nhau cho AEM Page

Bài cơ bản đã dựng pipeline DTO -> Gson -> HTL ở seo-ld-json. Note này đi sâu vào phần khó nhất của structured data thực tế: object lồng nhau nhiều tầng (author, publisher, mainEntityOfPage, logo), mảng object (BreadcrumbList, FAQPage), URL tuyệt đối, và format ngày ISO 8601.

JSON mục tiêu

Toàn bộ bài xoay quanh việc generate đúng cấu trúc NewsArticle sau. Chú ý độ sâu: publisher.logo là tầng thứ ba.

{
    "@context": "https://schema.org",
    "@type": "NewsArticle",
    "inLanguage": "en-GB",
    "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "https://example.com"
    },
    "headline": "headline title",
    "description": "description",
    "image": "https://example.com/image.jpg",
    "author": {
        "@type": "Organization",
        "name": "Author Name",
        "url": "https://example.com"
    },
    "publisher": {
        "@type": "Organization",
        "name": "Publisher Name",
        "logo": {
            "@type": "ImageObject",
            "url": "https://example.com/logo.png"
        }
    },
    "datePublished": "2025-10-15T10:00:00+00:00",
    "dateModified": "2025-10-15T10:00:00+00:00",
    "articleBody": "article body"
}

Nguyên tắc map cây JSON sang DTO lồng nhau

Mỗi object con ({ }) trong JSON tương ứng một class Java riêng. Mỗi key @type/@id/@context map bằng @SerializedName vì tên có ký tự @ không hợp lệ làm field Java.

Quy tắc: một object con dùng lại nhiều nơi (như Organization) thì tách thành class top-level, dùng một lần thì để static nested class cho gọn.

DTO đầy đủ cho NewsArticle

Tách các kiểu dùng lại (Organization, ImageObject, WebPage) thành class riêng để tái sử dụng cho schema khác.

package com.myproject.core.models.seo;

import com.google.gson.annotations.SerializedName;

public class NewsArticleSchema {

    @SerializedName("@context")
    private final String context = "https://schema.org";

    @SerializedName("@type")
    private final String type = "NewsArticle";

    private String inLanguage;
    private WebPage mainEntityOfPage;
    private String headline;
    private String description;
    private String image;
    private Organization author;
    private Organization publisher;
    private String datePublished;
    private String dateModified;
    private String articleBody;

    // Getters/Setters cho moi field
    public void setInLanguage(String v) { this.inLanguage = v; }
    public void setMainEntityOfPage(WebPage v) { this.mainEntityOfPage = v; }
    public void setHeadline(String v) { this.headline = v; }
    public void setDescription(String v) { this.description = v; }
    public void setImage(String v) { this.image = v; }
    public void setAuthor(Organization v) { this.author = v; }
    public void setPublisher(Organization v) { this.publisher = v; }
    public void setDatePublished(String v) { this.datePublished = v; }
    public void setDateModified(String v) { this.dateModified = v; }
    public void setArticleBody(String v) { this.articleBody = v; }
}

Class WebPage cho mainEntityOfPage. Chỉ chứa @type và @id.

package com.myproject.core.models.seo;

import com.google.gson.annotations.SerializedName;

public class WebPage {

    @SerializedName("@type")
    private final String type = "WebPage";

    @SerializedName("@id")
    private String id;

    public WebPage(String id) { this.id = id; }

    public void setId(String v) { this.id = v; }
}

Class Organization dùng cho cả author và publisher. logo là field optional kiểu ImageObject. Gson bỏ qua field null nên author không có logo sẽ tự động không xuất hiện trong JSON.

package com.myproject.core.models.seo;

import com.google.gson.annotations.SerializedName;

public class Organization {

    @SerializedName("@type")
    private final String type = "Organization";

    private String name;
    private String url;
    private ImageObject logo;

    public void setName(String v) { this.name = v; }
    public void setUrl(String v) { this.url = v; }
    public void setLogo(ImageObject v) { this.logo = v; }
}

Class ImageObject cho publisher.logo, tầng lồng sâu nhất.

package com.myproject.core.models.seo;

import com.google.gson.annotations.SerializedName;

public class ImageObject {

    @SerializedName("@type")
    private final String type = "ImageObject";

    private String url;

    public ImageObject(String url) { this.url = url; }

    public void setUrl(String v) { this.url = v; }
}

Sling Model lắp ráp object lồng nhau

Điểm mấu chốt: lắp từ trong ra ngoài. Tạo ImageObject trước, gắn vào Organization publisher, rồi gắn publisher vào NewsArticleSchema.

package com.myproject.core.models;

import com.day.cq.commons.Externalizer;
import com.day.cq.wcm.api.Page;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.myproject.core.models.seo.*;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.ScriptVariable;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;

import javax.annotation.PostConstruct;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Calendar;

@Model(adaptables = SlingHttpServletRequest.class,
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class NewsArticleSeoModel {

    @ScriptVariable
    private Page currentPage;

    @SlingObject
    private ResourceResolver resourceResolver;

    @OSGiService
    private Externalizer externalizer;

    private String jsonLdString;

    @PostConstruct
    protected void init() {
        if (currentPage == null) {
            return;
        }
        var props = currentPage.getProperties();

        NewsArticleSchema schema = new NewsArticleSchema();
        schema.setInLanguage(resolveLanguage());
        schema.setHeadline(props.get("jcr:title", currentPage.getName()));
        schema.setDescription(props.get("jcr:description", ""));
        schema.setArticleBody(props.get("articleBody", ""));

        // URL tuyet doi cua chinh page
        String pageUrl = toAbsoluteUrl(currentPage.getPath());
        schema.setMainEntityOfPage(new WebPage(pageUrl));

        // image: chuyen DAM path sang URL tuyet doi
        String imagePath = props.get("fileReference", String.class);
        if (StringUtils.isNotBlank(imagePath)) {
            schema.setImage(toAbsoluteUrl(imagePath));
        }

        // author: Organization mot tang
        Organization author = new Organization();
        author.setName(props.get("authorName", "My Company"));
        author.setUrl(toAbsoluteUrl("/content/myproject/en"));
        schema.setAuthor(author);

        // publisher: Organization chua logo ImageObject (hai tang)
        Organization publisher = new Organization();
        publisher.setName("My Company");
        publisher.setLogo(new ImageObject(toAbsoluteUrl("/content/dam/myproject/logo.png")));
        schema.setPublisher(publisher);

        // datePublished / dateModified theo ISO 8601
        schema.setDatePublished(toIso8601(props.get("cq:lastReplicated", Calendar.class)));
        schema.setDateModified(toIso8601(props.get("cq:lastModified", Calendar.class)));

        // Gson: tat HTML escaping de khong bien & thanh \u0026
        Gson gson = new GsonBuilder().disableHtmlEscaping().create();
        this.jsonLdString = gson.toJson(schema);
    }

    private String resolveLanguage() {
        return currentPage.getLanguage(false) != null
            ? currentPage.getLanguage(false).toLanguageTag()
            : "en-GB";
    }

    private String toAbsoluteUrl(String path) {
        // Externalizer them scheme + host theo cau hinh PUBLISH externalizer
        return externalizer.publishLink(resourceResolver, path);
    }

    private String toIso8601(Calendar cal) {
        if (cal == null) {
            return null;
        }
        ZonedDateTime zdt = cal.toInstant().atZone(ZoneOffset.UTC);
        return zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"));
    }

    public String getJsonLdString() {
        return jsonLdString;
    }
}

URL tuyệt đối bằng Externalizer

Schema.org yêu cầu image, @id, logo.url, author.url là URL tuyệt đối. Không bao giờ hardcode domain. Dùng OSGi Externalizer để lấy host theo môi trường.

// SAI: hardcode domain, vo dung khi doi moi truong
schema.setImage("https://example.com" + imagePath);

// DUNG: Externalizer doc config theo domain publish
schema.setImage(externalizer.publishLink(resourceResolver, imagePath));

Cấu hình tại /system/console/configMgr -> Day CQ Link Externalizer. Khai báo các domain local, author, publish. publishLink dùng domain publish.

Tip

Externalizer trả về path không có extension cho page. Với page cần .html, gọi externalizer.publishLink(resolver, currentPage.getPath() + ".html"). Với DAM asset thì giữ nguyên path gốc vì đã có extension file.

Format ngày ISO 8601 có timezone

Google yêu cầu datePublished/dateModified đúng ISO 8601 kèm offset. Property AEM lưu kiểu Calendar, phải convert.

// cq:lastModified la Calendar -> "2025-10-15T10:00:00+00:00"
ZonedDateTime zdt = cal.toInstant().atZone(ZoneOffset.UTC);
String iso = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"));

Pattern XXX cho ra +00:00. Nếu dùng giờ local thay vì UTC, đổi ZoneOffset.UTC thành ZoneId.of("Europe/London").

Property AEMY nghiaMap sang
cq:lastReplicatedLan publish gan nhatdatePublished
cq:lastModifiedLan sua noi dung gan nhatdateModified
jcr:createdNgay tao nodeFallback cho datePublished

Mảng object lồng nhau: BreadcrumbList

Schema dạng list dùng List<T>, mỗi phần tử lại chứa object con. Gson serialize List thành mảng JSON tự động.

public class BreadcrumbListSchema {
    @SerializedName("@context")
    private final String context = "https://schema.org";
    @SerializedName("@type")
    private final String type = "BreadcrumbList";
    private List<ListItem> itemListElement = new ArrayList<>();

    public void addItem(ListItem item) { this.itemListElement.add(item); }
}

public class ListItem {
    @SerializedName("@type")
    private final String type = "ListItem";
    private int position;
    private String name;
    private String item; // URL tuyet doi

    public ListItem(int position, String name, String item) {
        this.position = position;
        this.name = name;
        this.item = item;
    }
}

Build trong model bằng cách duyệt cây page cha.

BreadcrumbListSchema breadcrumb = new BreadcrumbListSchema();
int pos = 1;
Page p = currentPage;
List<Page> trail = new ArrayList<>();
while (p != null && p.getDepth() >= 3) { // dung o site root
    trail.add(0, p);
    p = p.getParent();
}
for (Page page : trail) {
    breadcrumb.addItem(new ListItem(pos++, page.getTitle(),
        externalizer.publishLink(resourceResolver, page.getPath() + ".html")));
}

Mảng object hai tầng: FAQPage

FAQPage lồng Question, mỗi Question lồng acceptedAnswer kiểu Answer. Dữ liệu thường đến từ multifield trong dialog, đọc bằng @ChildResource.

public class FaqPageSchema {
    @SerializedName("@context")
    private final String context = "https://schema.org";
    @SerializedName("@type")
    private final String type = "FAQPage";
    private List<Question> mainEntity = new ArrayList<>();
    public void addQuestion(Question q) { this.mainEntity.add(q); }
}

public class Question {
    @SerializedName("@type")
    private final String type = "Question";
    private String name;          // cau hoi
    private Answer acceptedAnswer; // object con
    public Question(String name, String answerText) {
        this.name = name;
        this.acceptedAnswer = new Answer(answerText);
    }
}

public class Answer {
    @SerializedName("@type")
    private final String type = "Answer";
    private String text;
    public Answer(String text) { this.text = text; }
}

Đọc multifield FAQ từ child resource và build.

@ChildResource(name = "faqItems")
private List<Resource> faqItems;

// trong @PostConstruct
FaqPageSchema faq = new FaqPageSchema();
if (faqItems != null) {
    for (Resource item : faqItems) {
        var vm = item.getValueMap();
        faq.addQuestion(new Question(
            vm.get("question", ""),
            vm.get("answer", "")));
    }
}

Render nhiều schema cùng lúc

Một page có thể cần NewsArticle lẫn BreadcrumbList. Mỗi schema một thẻ <script> riêng, đừng gộp vào một array trừ khi dùng @graph.

<sly data-sly-use.seo="com.myproject.core.models.NewsArticleSeoModel"></sly>

<sly data-sly-test="${seo.jsonLdString}">
  <script type="application/ld+json">${seo.jsonLdString @ context='unsafe'}</script>
</sly>

<sly data-sly-test="${seo.breadcrumbJson}">
  <script type="application/ld+json">${seo.breadcrumbJson @ context='unsafe'}</script>
</sly>

Muốn gộp nhiều entity trong một script thì dùng @graph.

{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "NewsArticle", "headline": "..." },
    { "@type": "BreadcrumbList", "itemListElement": [] }
  ]
}

Lỗi thường gặp

Trieu chungNguyen nhanCach sua
& bien thanh \u0026 trong JSONGson mac dinh bat HTML escapingnew GsonBuilder().disableHtmlEscaping()
Object con thanh {} rongQuen getter hoac field null toan boSet du field, kiem tra null nguon
image la path tuong doi /content/dam/...Khong externalizeDung externalizer.publishLink
datePublished sai dinh dangIn thang Calendar.toString()Convert sang ISO 8601 pattern XXX
Field null van xuat hien la nullHiem, do cau hinh serializeNullsKhong goi serializeNulls, Gson mac dinh bo null
Validator bao thieu @typeField type bi static khong khoi taoKhoi tao private final String type = "..."
Tip

Sau khi publish, dán URL vào Rich Results Test và Schema Markup Validator. Kiểm tra từng tầng lồng: validator báo rõ object con nào thiếu field bắt buộc, ví dụ publisher.logo thiếu url.