> 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.

# Externalized ValueMap

`@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.

## Why Externalization Matters

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:

```markdown
Internal:   /content/my-site/en/about
External:   https://www.my-site.com/en/about.html
```

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.

<svg id="mermaid-svg-8551466" width="100%" xmlns="http://www.w3.org/2000/svg" style="max-width: 1304.791748046875px;" viewBox="0 0 1304.791748046875 114" role="graphics-document document" aria-roledescription="flowchart-v2"><g><marker id="mermaid-svg-8551466_flowchart-v2-pointEnd" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-pointStart" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-pointEnd-margin" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-pointStart-margin" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mermaid-svg-8551466_flowchart-v2-circleEnd" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-svg-8551466_flowchart-v2-circleStart" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-svg-8551466_flowchart-v2-circleEnd-margin" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-svg-8551466_flowchart-v2-circleStart-margin" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-svg-8551466_flowchart-v2-crossEnd" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-crossStart" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-crossEnd-margin" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" style="stroke-width: 2.5;"></path></marker><marker id="mermaid-svg-8551466_flowchart-v2-crossStart-margin" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g><g></g><g><path d="M196.042,57L200.208,57C204.375,57,212.708,57,220.375,57C228.042,57,235.042,57,238.542,57L242.042,57" id="mermaid-svg-8551466-L_Author_JCR_0" style=";" data-edge="true" data-et="edge" data-id="L_Author_JCR_0" data-points="W3sieCI6MTk2LjA0MTY3MTc1MjkyOTcsInkiOjU3fSx7IngiOjIyMS4wNDE2NzE3NTI5Mjk3LCJ5Ijo1N30seyJ4IjoyNDYuMDQxNjcxNzUyOTI5NywieSI6NTd9XQ==" data-look="classic" marker-end="url(#mermaid-svg-8551466_flowchart-v2-pointEnd)" fill="none" stroke="currentColor"></path><path d="M506.042,57L510.208,57C514.375,57,522.708,57,530.375,57C538.042,57,545.042,57,548.542,57L552.042,57" id="mermaid-svg-8551466-L_JCR_Injector_0" style=";" data-edge="true" data-et="edge" data-id="L_JCR_Injector_0" data-points="W3sieCI6NTA2LjA0MTY3MTc1MjkyOTcsInkiOjU3fSx7IngiOjUzMS4wNDE2NzE3NTI5Mjk3LCJ5Ijo1N30seyJ4Ijo1NTYuMDQxNjcxNzUyOTI5NywieSI6NTd9XQ==" data-look="classic" marker-end="url(#mermaid-svg-8551466_flowchart-v2-pointEnd)" fill="none" stroke="currentColor"></path><path d="M824.052,57L828.219,57C832.385,57,840.719,57,848.385,57C856.052,57,863.052,57,866.552,57L870.052,57" id="mermaid-svg-8551466-L_Injector_ExtService_0" style=";" data-edge="true" data-et="edge" data-id="L_Injector_ExtService_0" data-points="W3sieCI6ODI0LjA1MjA3ODI0NzA3MDMsInkiOjU3fSx7IngiOjg0OS4wNTIwNzgyNDcwNzAzLCJ5Ijo1N30seyJ4Ijo4NzQuMDUyMDc4MjQ3MDcwMywieSI6NTd9XQ==" data-look="classic" marker-end="url(#mermaid-svg-8551466_flowchart-v2-pointEnd)" fill="none" stroke="currentColor"></path><path d="M1072.583,57L1076.75,57C1080.917,57,1089.25,57,1096.917,57C1104.583,57,1111.583,57,1115.083,57L1118.583,57" id="mermaid-svg-8551466-L_ExtService_Output_0" style=";" data-edge="true" data-et="edge" data-id="L_ExtService_Output_0" data-points="W3sieCI6MTA3Mi41ODMzMjgyNDcwNzAzLCJ5Ijo1N30seyJ4IjoxMDk3LjU4MzMyODI0NzA3MDMsInkiOjU3fSx7IngiOjExMjIuNTgzMzI4MjQ3MDcwMywieSI6NTd9XQ==" data-look="classic" marker-end="url(#mermaid-svg-8551466_flowchart-v2-pointEnd)" fill="none" stroke="currentColor"></path></g><g><g><g data-id="L_Author_JCR_0" transform="translate(0, 0)"></g></g><g><g data-id="L_JCR_Injector_0" transform="translate(0, 0)"></g></g><g><g data-id="L_Injector_ExtService_0" transform="translate(0, 0)"></g></g><g><g data-id="L_ExtService_Output_0" transform="translate(0, 0)"></g></g></g><g><g id="mermaid-svg-8551466-flowchart-Author-0" data-look="classic" transform="translate(102.02083587646484, 57)"><rect style="" x="-94.02083587646484" y="-37" width="188.0416717529297" height="74" fill="none" stroke="currentColor"></rect><g style="" transform="translate(-64.02083587646484, -22)"><rect></rect><foreignObject width="128.0416717529297" height="44"><p>Author saves path</p></foreignObject></g></g><g id="mermaid-svg-8551466-flowchart-JCR-1" data-look="classic" transform="translate(376.0416717529297, 57)"><rect style="" x="-130" y="-49" width="260" height="98" fill="none" stroke="currentColor"></rect><g style="" transform="translate(-100, -34)"><rect></rect><foreignObject width="200" height="68"><p>JCR: /content/my-site/en/about</p></foreignObject></g></g><g id="mermaid-svg-8551466-flowchart-Injector-2" data-look="classic" transform="translate(690.046875, 57)"><rect style="" x="-134.00521087646484" y="-37" width="268.0104217529297" height="74" fill="none" stroke="currentColor"></rect><g style="" transform="translate(-104.00521087646484, -22)"><rect></rect><foreignObject width="208.0104217529297" height="44"><p>@ExternalizedValueMapValue</p></foreignObject></g></g><g id="mermaid-svg-8551466-flowchart-ExtService-3" data-look="classic" transform="translate(973.3177032470703, 57)"><rect style="" x="-99.265625" y="-37" width="198.53125" height="74" fill="none" stroke="currentColor"></rect><g style="" transform="translate(-69.265625, -22)"><rect></rect><foreignObject width="138.53125" height="44"><p>ExternalizerService</p></foreignObject></g></g><g id="mermaid-svg-8551466-flowchart-Output-4" data-look="classic" transform="translate(1209.6874923706055, 57)"><rect style="" x="-87.10416793823242" y="-37" width="174.20833587646484" height="74" fill="none" stroke="currentColor"></rect><g style="" transform="translate(-57.10416793823242, -22)"><rect></rect><foreignObject width="114.20833587646484" height="44"><p>/en/about.html</p></foreignObject></g></g></g></g></g><defs></defs><defs></defs><linearGradient id="mermaid-svg-8551466-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#cccccc" stop-opacity="1"></stop><stop offset="100%" stop-color="hsl(180, 0%, 18.3529411765%)" stop-opacity="1"></stop></linearGradient></svg>

## Comparison with @ValueMapValue

| Feature            | `@ValueMapValue`                        | `@ExternalizedValueMapValue`               |
| ------------------ | --------------------------------------- | ------------------------------------------ |
| Reads JCR property | Yes                                     | Yes                                        |
| Returns raw value  | Yes                                     | No - externalizes it                       |
| Works with paths   | Yes, but returns raw path               | Yes, returns mapped URL                    |
| Use case           | Simple values (text, numbers, booleans) | Path/link properties that need URL mapping |
| Built-in           | Yes (Apache Sling)                      | No (custom annotation + injector)          |

### Before and after

```java
// Without externalization - raw JCR path
@ValueMapValue
private String linkUrl;
// Returns: /content/my-site/en/about

// With externalization - mapped URL
@ExternalizedValueMapValue
private String linkUrl;
// Returns: /en/about.html (or full URL depending on configuration)
```

## The Externalizer Concept

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:

```markdown
ResourceResolver.map("/content/my-site/en/about")
    → applies /etc/map rules
    → strips /content/my-site prefix
    → returns "/en/about.html"
```

For fully qualified URLs (including scheme and domain), AEM provides the `com.day.cq.commons.Externalizer` OSGi service:

```java
// ResourceResolver.map() - relative, context-aware
String relative = resolver.map(request, "/content/my-site/en/about");
// → "/en/about.html"

// Externalizer - absolute URL with domain
String absolute = externalizer.publishLink(resolver, "/content/my-site/en/about");
// → "https://www.my-site.com/en/about.html"
```

| Method                                     | Returns                   | Use case                         |
| ------------------------------------------ | ------------------------- | -------------------------------- |
| `resolver.map(path)`                       | Relative mapped path      | Same-site links, SPA routing     |
| `resolver.map(request, path)`              | Context-aware mapped path | Request-scoped links             |
| `externalizer.publishLink(resolver, path)` | Absolute publish URL      | Emails, social sharing, sitemaps |
| `externalizer.authorLink(resolver, path)`  | Absolute author URL       | Admin links, internal tools      |

## Use Cases

### SPA (Single Page Application) Routing

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:

```json
{
    "linkUrl": "/en/about.html",
    "imageUrl": "/content/dam/my-site/hero.jpg"
}
```

Without externalization, the SPA would receive `/content/my-site/en/about`, which doesn't match any client-side route.

### Headless JSON Exports

When AEM serves as a headless CMS via the Sling Model Exporter (`.model.json`), all path properties must be externalized for external consumers.

### Email Templates

Links in email templates need fully qualified URLs (`https://www.my-site.com/en/about.html`) since email clients cannot resolve relative paths.

### Social Media / SEO

Open Graph tags, canonical URLs, and sitemap entries all require absolute externalized URLs.

## Practical Usage

### Sling Model with @ExternalizedValueMapValue

```java
@Model(
    adaptables = SlingHttpServletRequest.class,
    adapters = {TeaserModel.class, ComponentExporter.class},
    resourceType = TeaserModel.RESOURCE_TYPE,
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
@Exporter(name = "jackson", extensions = "json")
public class TeaserModel implements ComponentExporter {

    static final String RESOURCE_TYPE = "myproject/components/teaser";

    // Regular property - no path transformation needed
    @ValueMapValue
    private String title;

    @ValueMapValue
    private String description;

    // Path property - automatically externalized
    @ExternalizedValueMapValue
    private String linkUrl;

    // Another path property
    @ExternalizedValueMapValue
    private String ctaLink;

    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public String getLinkUrl() { return linkUrl; }
    public String getCtaLink() { return ctaLink; }

    @Override
    public String getExportedType() { return RESOURCE_TYPE; }
}
```

### HTL Template

```html
<sly data-sly-use.model="com.myproject.core.models.TeaserModel"/>

<div class="teaser">
    <h2>${model.title}</h2>
    <p>${model.description}</p>
    <a data-sly-test="${model.linkUrl}"
       href="${model.linkUrl}"
       class="teaser__cta">
        Read more
    </a>
</div>
```

Since `linkUrl` is already externalized by the injector, you can use it directly without further transformation in HTL.

### JSON Export Result

Requesting the component with `.model.json`:

```json
{
    ":type": "myproject/components/teaser",
    "title": "Our Story",
    "description": "Learn about our company...",
    "linkUrl": "/en/about.html",
    "ctaLink": "/en/contact.html"
}
```

## Implementation Details

Creating `@ExternalizedValueMapValue` requires two parts: the annotation interface and the injector OSGi service.

### The Annotation Interface

The annotation defines the contract and links to the custom Sling Models injector via `@Source`:

```java
@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) |
| `injectionStrategy` | Control behavior when the property is missing                    |

### The Injector Service

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:

```java
@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 ExternalizerService

The actual URL mapping is done by the `ExternalizerService`. A minimal implementation uses `ResourceResolver.map()`:

```java
@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:

```java
@Override
public String externalizeForSPARouter(SlingHttpServletRequest request, String path) {
    if (StringUtils.isBlank(path)) {
        return null;
    }
    String mapped = request.getResourceResolver().map(request, path);
    // Remove .html extension for SPA client-side routing
    return StringUtils.removeEnd(mapped, ".html");
}
```

## Externalizer OSGi Configuration

AEM's built-in `com.day.cq.commons.impl.ExternalizerImpl` must be configured with the correct domains for each environment:

```json
{
    "externalizer.domains": [
        "local https://localhost:4502",
        "author https://author.my-site.com",
        "publish https://www.my-site.com"
    ],
    "externalizer.encodedpath": true
}
```

| Domain    | Purpose                 | Example output                             |
| --------- | ----------------------- | ------------------------------------------ |
| `local`   | Local development       | `https://localhost:4502/en/about.html`     |
| `author`  | Author instance         | `https://author.my-site.com/en/about.html` |
| `publish` | Publish / public facing | `https://www.my-site.com/en/about.html`    |

:::info 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.:::

## Alternative Approaches

If you don't want to build a custom annotation, there are simpler ways to externalize paths:

### Manual externalization in @PostConstruct

```java
@ValueMapValue
private String linkUrl;

private String externalizedLinkUrl;

@PostConstruct
protected void init() {
    if (StringUtils.isNotBlank(linkUrl)) {
        externalizedLinkUrl = request.getResourceResolver().map(request, linkUrl);
    }
}

public String getLinkUrl() {
    return externalizedLinkUrl;
}
```

### Using AEM's built-in Externalizer directly

```java
@OSGiService
private com.day.cq.commons.Externalizer externalizer;

@ValueMapValue
private String linkUrl;

public String getAbsoluteUrl() {
    if (StringUtils.isBlank(linkUrl)) return null;
    return externalizer.publishLink(resourceResolver, linkUrl);
}
```

### When to use which approach

| Approach                     | Best for                                              | Complexity                                     |
| ---------------------------- | ----------------------------------------------------- | ---------------------------------------------- |
| `@ExternalizedValueMapValue` | 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.

## Best Practices and Common Pitfalls

### Only externalize path properties

Don't apply `@ExternalizedValueMapValue` to non-path properties (titles, descriptions, etc.). The externalizer will attempt to map them and may return unexpected results.

### Handle null and empty paths

The injector should return `null` for blank values. Always null-check in your getter:

```java
public String getLinkUrl() {
    return StringUtils.defaultIfBlank(linkUrl, "#");
}
```

### Be aware of author vs publish differences

`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.

### Avoid open redirects

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.

### Don't double-externalize

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.

### Performance

`ResourceResolver.map()` is lightweight (in-memory mapping table lookup), so externalization has negligible performance impact. There is no need to cache externalized values.
