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/annotations/externalized-value-map-value.md.
@ExternalizedValueMapValue is a custom Sling Models injector annotation that reads a JCR property (like @ValueMapValue) and automatically externalizes the value - converting internal content paths into proper external URLs. This is essential for SPA-based AEM projects, headless JSON exports, and any scenario where internal JCR paths must be transformed to public-facing URLs.
AEM stores content paths internally as JCR references such as /content/my-site/en/about. These paths are meaningless to an end user or an external consumer. The Externalizer transforms them into proper URLs:
Without externalization, links in JSON exports, SPA frontends, or emails will contain raw JCR paths that don't resolve to real URLs on the publish tier.
AEM provides ResourceResolver.map() to convert internal paths to external URLs. This method applies /etc/map rules and Sling mappings to shorten and transform paths:
In AEM SPA Editor projects, the JSON model exporter delivers component data to the React/Angular frontend. All internal paths must be externalized so the SPA router can resolve them:
The annotation defines the contract and links to the custom Sling Models injector via @Source:
@Target({FIELD})@Retention(RUNTIME)@InjectAnnotation@Source(ExternalizedValueMapValue.ANNOTATION_NAME)public @interface ExternalizedValueMapValue { String ANNOTATION_NAME = "externalized"; /** * Override the JCR property name if it differs from the Java field name. */ String name() default ""; /** * Controls injection behavior: REQUIRED, OPTIONAL, or DEFAULT. * DEFAULT falls back to the @Model-level defaultInjectionStrategy. */ InjectionStrategy injectionStrategy() default InjectionStrategy.DEFAULT;}
Attribute
Purpose
name
Override the JCR property name (defaults to the Java field name)
The injector is an OSGi service that implements both Injector and StaticInjectAnnotationProcessorFactory. It reads the raw JCR property, then passes it through the ExternalizerService before injection:
@Component( immediate = true, service = {Injector.class, StaticInjectAnnotationProcessorFactory.class})public class ExternalizedValueMapValueInjector implements Injector, StaticInjectAnnotationProcessorFactory { @Reference private ExternalizerService externalizerService; @Override public String getName() { // Must match the @Source annotation name return ExternalizedValueMapValue.ANNOTATION_NAME; } @Override public Object getValue(Object adaptable, String name, Type type, AnnotatedElement annotatedElement, DisposalCallbackRegistry disposalCallbackRegistry) { // Only process String fields annotated with @ExternalizedValueMapValue if (!(type instanceof Class) || !String.class.isAssignableFrom((Class<?>) type)) { return null; } if (!annotatedElement.isAnnotationPresent(ExternalizedValueMapValue.class)) { return null; } // Resolve the request and resource from the adaptable SlingHttpServletRequest request = null; Resource resource = null; if (adaptable instanceof SlingHttpServletRequest) { request = (SlingHttpServletRequest) adaptable; resource = request.getResource(); } else if (adaptable instanceof Resource) { resource = (Resource) adaptable; } if (request == null || resource == null) { return null; } ExternalizedValueMapValue annotation = annotatedElement.getAnnotation(ExternalizedValueMapValue.class); // Use the annotation's name attribute, or fall back to the field name String key = StringUtils.defaultIfBlank(annotation.name(), name); // Read the raw property value from the component's resource String rawValue = resource.getValueMap().get(key, String.class); // Externalize the path if it has a value if (StringUtils.isNotBlank(rawValue)) { return externalizerService.externalize(request, rawValue); } return null; } @Override public InjectAnnotationProcessor2 createAnnotationProcessor( AnnotatedElement annotatedElement) { return Optional.ofNullable( annotatedElement.getAnnotation(ExternalizedValueMapValue.class)) .map(ExternalizedValueMapValueInjectorProcessor::new) .orElse(null); } // Processes injection strategy and optional flag from the annotation private static class ExternalizedValueMapValueInjectorProcessor extends AbstractInjectAnnotationProcessor2 { private final ExternalizedValueMapValue annotation; public ExternalizedValueMapValueInjectorProcessor( ExternalizedValueMapValue annotation) { this.annotation = annotation; } @Override public InjectionStrategy getInjectionStrategy() { return annotation.injectionStrategy(); } }}
The actual URL mapping is done by the ExternalizerService. A minimal implementation uses ResourceResolver.map():
@Component(service = ExternalizerService.class)public class ExternalizerServiceImpl implements ExternalizerService { @Override public String externalize(SlingHttpServletRequest request, String path) { if (StringUtils.isBlank(path)) { return null; } // Applies /etc/map rules and Sling mappings return request.getResourceResolver().map(request, path); }}
For SPA projects that need client-side routing (where .html extensions and /content/ prefixes are stripped), you would implement a specialized version:
AEMaaCS On AEM as a Cloud Service, the Externalizer domains are configured automatically by Cloud Manager. You typically don't need to configure them manually. Use
ResourceResolver.map() for relative URLs and the built-in Externalizer for absolute URLs.
Projects with many path properties across many models
High (one-time setup), then very low per-model
Manual @PostConstruct
One or two models with path properties
Low per-model, but repetitive
Built-in Externalizer
Absolute URLs (emails, sitemaps, OG tags)
Low
HTL @ context='uri'
Simple links in templates (no JSON export)
Lowest
[!-success] -success
tip
The custom @ExternalizedValueMapValue annotation pays off in larger SPA projects where dozens of components have path properties. For simpler projects, manual externalization in @PostConstruct or using the built-in Externalizer directly is often sufficient.
Don't apply @ExternalizedValueMapValue to non-path properties (titles, descriptions, etc.). The externalizer will attempt to map them and may return unexpected results.
ResourceResolver.map() returns different results depending on the environment because /etc/map rules differ between author and publish. Always test externalized URLs on both tiers.
If externalized URLs are used in redirect logic, validate that the resulting URL belongs to your domain. Never blindly redirect to an externalized user-provided path without validation.
If you use @ExternalizedValueMapValue in your Sling Model, don't also apply @ context='uri' in HTL or call resolver.map() again. The path is already mapped.
ResourceResolver.map() is lightweight (in-memory mapping table lookup), so externalization has negligible performance impact. There is no need to cache externalized values.