Skip to main content

Tags and Taxonomies

AEM's tagging framework provides a structured way to classify and organise content. Tags are first-class JCR nodes stored under /content/cq:tags/ and can be applied to pages, assets, Content Fragments, and any resource with a cq:tags property. They power navigation, search facets, analytics segmentation, and personalisation.


Tag Structure​

Namespaces​

Every tag belongs to a namespace - a top-level container that groups related tags. Namespaces are the direct children of /content/cq:tags/:

/content/cq:tags/
├── marketing/ ← namespace
│ ├── campaigns/
│ │ ├── 2024/
│ │ └── 2025/
│ └── channels/
│ ├── email
│ ├── social
│ └── web
├── products/ ← namespace
│ ├── shoes/
│ │ ├── running
│ │ └── hiking
│ └── clothing/
└── regions/ ← namespace
├── emea
├── americas
└── apac

Tag node type​

Tags use the cq:Tag node type with these key properties:

PropertyTypeDescription
jcr:titleStringDisplay title (localisable)
jcr:descriptionStringDescription
sling:resourceTypeStringcq/tagging/components/tag
jcr:title.deStringGerman title (i18n)
jcr:title.frStringFrench title (i18n)

Tag ID format​

Tags are identified by their tag ID, which is the path relative to /content/cq:tags/ using a colon (:) to separate the namespace from the path:

marketing:campaigns/2025
products:shoes/running
regions:emea

The cq:tags property on a resource stores an array of these tag IDs:

cq:tags = ["marketing:campaigns/2025", "products:shoes/running"]

Creating and Managing Tags​

Via the Tagging Console​

Navigate to Tools > General > Tagging (/aem/tagging) in the AEM author instance. From here you can:

  • Create namespaces and child tags
  • Edit tag titles and descriptions
  • Move and merge tags
  • Localise tag titles for multiple languages
  • Delete unused tags

Programmatically via TagManager​

The TagManager API is the primary interface for working with tags in Java:

import com.day.cq.tagging.Tag;
import com.day.cq.tagging.TagManager;

// Obtain TagManager from a ResourceResolver
TagManager tagManager = resourceResolver.adaptTo(TagManager.class);

Creating tags​

// Create a tag (creates parent tags if they don't exist)
Tag newTag = tagManager.createTag(
"marketing:campaigns/2025/summer", // tag ID
"Summer 2025 Campaign", // title
"Tags for the summer 2025 campaign", // description
true // auto-save
);

Resolving tags​

// Resolve a single tag by ID
Tag tag = tagManager.resolve("marketing:campaigns/2025");

// Get the tag title (localised)
String title = tag.getTitle(Locale.GERMAN); // "Kampagnen 2025"
String defaultTitle = tag.getTitle(); // "2025"
String localTitle = tag.getLocalizedTitle(locale); // with fallback chain

// Get the full tag path
String path = tag.getPath(); // /content/cq:tags/marketing/campaigns/2025
String tagId = tag.getTagID(); // marketing:campaigns/2025
String namespace = tag.getNamespace().getName(); // marketing

Reading tags from a resource​

// Get all tags on a resource
Tag[] tags = tagManager.getTags(resource);

// Or read from the property directly
String[] tagIds = resource.getValueMap().get("cq:tags", String[].class);

Setting tags on a resource​

// Set tags on a resource (replaces existing tags)
Tag campaignTag = tagManager.resolve("marketing:campaigns/2025");
Tag channelTag = tagManager.resolve("marketing:channels/email");
tagManager.setTags(resource, new Tag[]{ campaignTag, channelTag }, true);

Finding tagged resources​

import com.day.cq.commons.RangeIterator;

// Find all resources under a search root that are tagged with ALL of the given tag IDs.
// Use the find(path, tagIds, oneMatchIsEnough) overload with oneMatchIsEnough=true for an
// any-match (OR) search instead. Returns null if a given tag ID does not resolve to a tag.
RangeIterator<Resource> resources = tagManager.find(
"/content/site",
new String[] { "marketing:campaigns/2025" });

if (resources != null) {
while (resources.hasNext()) {
Resource taggedResource = resources.next();
// ...
}
}

TagManager API reference​

MethodDescription
resolve(tagId)Resolve a tag by its ID
createTag(tagId, title, desc, autoSave)Create a new tag
getTags(resource)Get all tags on a resource
setTags(resource, tags, autoSave)Set tags on a resource
find(rootPath, tagIds)Find resources tagged with all of the given IDs (oneMatchIsEnough overload for any-match)
findByTitle(title)Find content tagged with a tag whose title matches (* wildcard supported); returns a TagManager.FindResults
findTagsByTitle(keyword, locale)Find Tag[] whose title matches a keyword
deleteTag(tag, autoSave)Delete a tag
moveTag(tag, newPath)Move/rename a tag
mergeTag(sourceTag, targetTag)Merge two tags
canCreateTag(tagId)Check if the current user can create a tag

Tags in Sling Models​

Reading tags from a component​

import com.day.cq.tagging.Tag;
import com.day.cq.tagging.TagManager;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.Self;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

@Model(adaptables = Resource.class)
public class TaggedContentModel {

@Self
private Resource resource;

@ValueMapValue(optional = true)
private String[] cqTags;

private List<TagInfo> tags;

@PostConstruct
protected void init() {
tags = new ArrayList<>();

if (cqTags == null || cqTags.length == 0) {
return;
}

TagManager tagManager = resource.getResourceResolver().adaptTo(TagManager.class);
if (tagManager == null) {
return;
}

for (String tagId : cqTags) {
Tag tag = tagManager.resolve(tagId);
if (tag != null) {
tags.add(new TagInfo(
tag.getTagID(),
tag.getTitle(),
tag.getPath()
));
}
}
}

public List<TagInfo> getTags() {
return Collections.unmodifiableList(tags);
}

public static class TagInfo {
private final String id;
private final String title;
private final String path;

public TagInfo(String id, String title, String path) {
this.id = id;
this.title = title;
this.path = path;
}

public String getId() { return id; }
public String getTitle() { return title; }
public String getPath() { return path; }
}
}

Localised tag titles​

@Model(adaptables = SlingHttpServletRequest.class)
public class LocalisedTagModel {

@Self
private SlingHttpServletRequest request;

@ValueMapValue(name = "cq:tags", optional = true)
private String[] tagIds;

public List<String> getTagTitles() {
if (tagIds == null) return Collections.emptyList();

Locale pageLocale = request.getLocale();
TagManager tagManager = request.getResourceResolver().adaptTo(TagManager.class);
if (tagManager == null) {
return Collections.emptyList();
}

List<String> titles = new ArrayList<>();

for (String id : tagIds) {
Tag tag = tagManager.resolve(id);
if (tag != null) {
// Returns the title in the page locale, with fallback to default
titles.add(tag.getLocalizedTitle(pageLocale));
}
}
return titles;
}
}

Tags in Dialog Fields​

Tag field​

The standard Granite UI tag field provides a tag picker with autocomplete:

_cq_dialog/.content.xml (excerpt)
<tags
jcr:primaryType="nt:unstructured"
sling:resourceType="cq/gui/components/coral/common/form/tagfield"
fieldLabel="Tags"
fieldDescription="Select one or more tags"
name="./cq:tags"
multiple="{Boolean}true"/>

Scoped to a namespace​

Restrict the tag picker to a specific namespace:

<tags
jcr:primaryType="nt:unstructured"
sling:resourceType="cq/gui/components/coral/common/form/tagfield"
fieldLabel="Product Tags"
name="./productTags"
multiple="{Boolean}true"
rootPath="/content/cq:tags/products"/>

Single tag selection​

For a single tag (no multi-select):

<category
jcr:primaryType="nt:unstructured"
sling:resourceType="cq/gui/components/coral/common/form/tagfield"
fieldLabel="Primary Category"
name="./primaryCategory"
multiple="{Boolean}false"/>

Rendering Tags in HTL​

Simple tag list​

<sly data-sly-use.model="com.myproject.core.models.TaggedContentModel"/>
<div class="tag-list" data-sly-test="${model.tags.size > 0}">
<ul>
<li data-sly-repeat="${model.tags}">
<span class="tag">${item.title}</span>
</li>
</ul>
</div>
<sly data-sly-use.model="com.myproject.core.models.TaggedContentModel"/>
<nav class="tags" data-sly-test="${model.tags.size > 0}">
<a data-sly-repeat="${model.tags}"
href="/content/site/en/tags.html?tag=${item.id}"
class="tag-link">
${item.title}
</a>
</nav>

Tag-Based Search with QueryBuilder​

Find pages with a specific tag​

path=/content/site/en
type=cq:Page
1_property=jcr:content/cq:tags
1_property.value=marketing:campaigns/2025
p.limit=20

Find pages with ANY of several tags (OR)​

path=/content/site/en
type=cq:Page
group.p.or=true
group.1_property=jcr:content/cq:tags
group.1_property.value=marketing:channels/email
group.2_property=jcr:content/cq:tags
group.2_property.value=marketing:channels/social

Using the tagid predicate​

The dedicated tagid predicate simplifies tag queries and supports inheritance:

path=/content/site/en
type=cq:Page
tagid=marketing:campaigns/2025
tagid.property=jcr:content/cq:tags

Tag search with inheritance​

The tagid predicate can match child tags automatically:

path=/content/site/en
type=cq:Page
tagid=marketing:campaigns
tagid.property=jcr:content/cq:tags
# This matches pages tagged with marketing:campaigns, marketing:campaigns/2024,
# marketing:campaigns/2025, etc.

JCR-SQL2 query​

SELECT * FROM [cq:PageContent] AS content
WHERE ISDESCENDANTNODE(content, '/content/site/en')
AND content.[cq:tags] = 'marketing:campaigns/2025'

Taxonomy Design Patterns​

Flat vs hierarchical​

PatternExampleBest for
Flatregions:emea, regions:americasSimple classification, no parent-child relationships
Hierarchicalregions:emea/uk, regions:emea/deFaceted navigation, tag inheritance, drill-down search

Multi-site tag governance​

In multi-site projects, decide where tags live:

StrategyTags locationUse case
Global shared/content/cq:tags/global/All sites share the same taxonomy
Per-site namespace/content/cq:tags/brand-a/, /content/cq:tags/brand-b/Each site has its own tags
Hybrid/content/cq:tags/shared/ + /content/cq:tags/brand-a/Shared base + site-specific extensions

Controlled tag namespaces​

To prevent authors from creating new tags (enforcing a controlled vocabulary):

  1. Restrict create/modify/delete permissions on the namespace under /content/cq:tags/<namespace>
  2. Grant those permissions only to taxonomy owners (for example a tag-administrators group)
  3. Scope tag picker fields with rootPath so authors select from the approved namespace

There is no supported cq:isContainer = false switch that makes a namespace read-only. Enforce a controlled vocabulary through repository permissions and workflow/governance.


Localising Tag Titles​

Tag titles can be translated for multi-language sites by adding locale-specific jcr:title properties:

/content/cq:tags/products/shoes
├── jcr:title = "Shoes"
├── jcr:title.de = "Schuhe"
├── jcr:title.fr = "Chaussures"
├── jcr:title.es = "Zapatos"
└── jcr:title.ja = "靴"

The tag.getLocalizedTitle(locale) method resolves titles with a fallback chain:

  1. Exact locale match (de_DE)
  2. Language match (de)
  3. Default title (no locale suffix)

Groovy Console Scripts​

List all tags in a namespace​

import com.day.cq.tagging.TagManager

def tagManager = resourceResolver.adaptTo(TagManager.class)
def namespace = tagManager.resolve("products")

def printTag(tag, depth) {
println " " * depth + tag.tagID + " → " + tag.title
tag.listChildren().each { child -> printTag(child, depth + 1) }
}

printTag(namespace, 0)

Find unused tags​

import com.day.cq.tagging.TagManager

def tagManager = resourceResolver.adaptTo(TagManager.class)
def namespace = tagManager.resolve("marketing")

def checkTag(tag) {
def count = tagManager.find("/content", tag.tagID).size()
if (count == 0) {
println "UNUSED: ${tag.tagID}"
}
tag.listChildren().each { child -> checkTag(child) }
}

checkTag(namespace)

Bulk-rename tags​

// DRY_RUN = true - set to false to execute
def DRY_RUN = true

import com.day.cq.tagging.TagManager

def tagManager = resourceResolver.adaptTo(TagManager.class)
def oldTag = tagManager.resolve("marketing:campaigns/old-name")

if (oldTag != null) {
if (DRY_RUN) {
println "Would move ${oldTag.tagID} to marketing:campaigns/new-name"
} else {
tagManager.moveTag(oldTag, "marketing:campaigns/new-name")
resourceResolver.commit()
println "Tag moved successfully"
}
}

AEMaaCS Considerations​

  • Tags are stored under /content/cq:tags/ and are usually packaged with content (ui.content)
  • Tag namespaces can be created in the project code and deployed via Cloud Manager
  • Tags created by authors at runtime need to be replicated to publish instances
  • Manage tags on author; publish tiers should receive tags through publication/distribution, not direct authoring
  • For headless delivery, tags can be exposed via the Assets HTTP API or GraphQL

Best Practices​

Design your taxonomy before you build​

Plan namespaces, hierarchy depth, and governance rules before creating tags. Restructuring a taxonomy after content is tagged is painful.

Use permissions for controlled vocabularies​

Prevent tag sprawl by restricting write permissions on taxonomy namespaces and assigning tag administration rights only to taxonomy owners.

Keep tag hierarchies shallow​

Two to three levels deep is usually sufficient. Deeply nested tags are hard to navigate and rarely provide meaningful classification value.

Always use tag IDs, never paths​

Store and reference tags by their tag ID (namespace:path) not the JCR path (/content/cq:tags/namespace/path). The TagManager APIs understand tag IDs and update content references during move/merge operations; hard-coded repository paths are more brittle.

Localise tag titles​

If your site is multi-language, add translated titles to tags. The tagging framework handles locale resolution automatically.


Common Pitfalls​

PitfallSolution
Tags not showing on publishTags must be activated/replicated; check the replication status in the Tagging Console
TagManager returns nullThe ResourceResolver does not have the com.day.cq.tagging bundle resolved; check OSGi console
Tag picker shows all tagsSet rootPath on the tag field to restrict to a specific namespace
Duplicate tags across namespacesEstablish clear namespace ownership; use a shared namespace for cross-cutting concerns
Performance with large tag treesUse tagid QueryBuilder predicate instead of property-based queries; consider Oak indexes on cq:tags
Tags lost during content copyInclude /content/cq:tags/ in your content package filter when migrating content
Authors creating ad-hoc tagsRestrict namespace write permissions and tag creation to administrators

See also​