A guide to rendering browser-stored content in the AEM authoring view from the cookie on the edit request, so the component still updates when its dialog refreshes.
What if the content for your component is not in the repository? A visitor did something in the browser, the page wrote a cookie, and later a component has to turn that cookie back into markup. On Publish, JavaScript can populate the list after the page loads. In the AEM editor, though, I'd rather have the component render its content directly so authors can see what they're working with. When we save the dialog, we also need AEM to render the updated component. For this example, we'll refresh the editor page and let our Sling Model read the cookie again.
So what can we do?
We're going to look at how to build a component that stores IDs in a cookie. In edit mode it renders those IDs while the page is drawn. On Publish it leaves the first response alone and fills the list with a second request. Swap the IDs for whatever content you store: pages, products, locations, articles… The request split stays the same.
You want a component that reflects something the browser already knows. Recently viewed pages are an obvious example. The same idea works for a saved-item list, a store the visitor picked, or a comparison set for example.
Authors still have a job to do. They choose a behavior in the dialog, and they need to see that authored component and a real rendition of it on the canvas. A placeholder that only fills in after publish (or on the live site) is a bad editing experience. The author themselves cannot tell whether the empty text is right, whether the component is broken, or any other number of things.
The published page has a different job. It should stay cacheable. The personal list belongs on a follow-up request that is never stored in the shared Dispatcher cache.
First, we need somewhere to store our saved items. For this example, I'll use a cookie named stored_items.
We don't need anything complicated here. If we're storing multiple items, we can just save their IDs as a comma-separated list. This makes it easy to split them apart when our Sling Model reads the cookie later.
The cookie should be created wherever the visitor interacts with the content. This could be when they view a page, click a save button, or visit a product.
A few things to keep in mind:
Path=/. We want the cookie available across the entire site, not just the page where it was created.
SameSite=Lax. This allows the cookie on normal same-site requests while limiting when it's sent cross-site.
Path=/ makes the cookie available across paths on the hostname it's valid for. It doesn't magically make it available across different domains, and this matters quite a bit in AEM.
The published website and Author instance will often have different hostnames. So if a visitor saves something on your published site, don't expect that same cookie to show up when you open the component in Author. For testing, you'll need to make sure the cookie exists on the Author hostname too, or provide a separate way for authors to preview representative items. Otherwise, your Sling Model could be working perfectly fine while your component sits there looking completely empty. This could easily get confusing for QA testers and authors, or anyone else working within your component.
If your site uses a consent manager, make sure you're only writing the cookie after the visitor accepts the appropriate category. If they opt out later, remove it.What About Cookie Consent?
Now that we're storing our saved IDs in the stored_items cookie, we need our Sling Model to read them.
Since cookies are part of the HTTP request, our model needs to adapt from SlingHttpServletRequest instead of Resource.
Here's a simplified example:
@Model( adaptables = SlingHttpServletRequest.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL ) public class BrowserStoredList {private static final String COOKIE_NAME = "stored_items";
@SlingObject private SlingHttpServletRequest request;
private List<String> savedIds = Collections.emptyList();
@PostConstruct protected void init() { Cookie[] cookies = request.getCookies();
if (cookies == null) { return; }
for (Cookie cookie : cookies) { if (COOKIE_NAME.equals(cookie.getName())) { savedIds = Arrays.stream(cookie.getValue().split(",")) .map(String::trim) .filter(id -> id.matches("[a-zA-Z0-9/_-]+")) .limit(20) .collect(Collectors.toList()); break; } } }
public List<String> getSavedIds() { return savedIds; } }
Nothing too crazy, we're just grabbing the cookie, splitting the IDs into a list, and filtering out anything that doesn't match our expected format. I've also limited the list to 20 items so we're not processing an unlimited number of IDs.
At this point, we have our saved IDs available in the Sling Model. From here, we can look up the corresponding AEM resources and return the properties our HTL needs, like the title and path.
One thing to remember: just because an ID came from our cookie doesn't mean we should trust it. We'll still need to make sure each resource exists and falls under an allowed content path before rendering it.
When an author saves the dialog, it updates the component's properties. It does not, by itself, rebuild the list. So to get around this, we can add an edit listener so AEM comes back with a new edit request. That request carries the cookie, and the model in the next section runs again. _cq_editConfig.xml, beside the component dialog:
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:EditConfig">
<cq:listeners
jcr:primaryType="cq:EditListenersConfig"
afteredit="REFRESH_PAGE"
afterinsert="REFRESH_PAGE"/>
</jcr:root>
afteredit handles the dialog after an author saves changes.
afterinsert handles the component after it's added to the page.
REFRESH_PAGE refreshes the editor page, giving our Sling Model a new request to read the cookie and render the component.
Now that our Sling Model can read the cookie, we need to decide when it should actually use that data to render our component. Here's where things get a little tricky.
On Publish, we don't want to render visitor-specific content directly into the page's HTML. That page needs to remain cacheable. But in the AEM editor, we want authors to see their saved items without relying on JavaScript to populate the component.
So how do we handle both?
We can check whether we're running on an Author instance in edit mode. If we are, our Sling Model can use the saved IDs from the request's cookie to populate the component. If we're on Publish, we'll leave that rendering to our client-side JavaScript, which we'll cover later.
Here's a simple way to check the authoring context in our Sling model:
@OSGiService private SlingSettingsService slingSettingsService;
public boolean isAuthorPreview() { return request != null && slingSettingsService != null && slingSettingsService.getRunModes().contains("author") && WCMMode.fromRequest(request) == WCMMode.EDIT; }
This way, we only render the cookie-based content server-side when we're in the AEM editor.
On Publish, the Sling Model won't render those saved items into the initial HTML. Instead, JavaScript will handle loading them after the page loads.
Same cookie, different rendering approach.
Now that our Sling Model handles the cookie, we can use HTL to display the saved items directly on the authoring canvas.
We really only need to check two things: are we in the authoring context, and does our list contain any items?
<sly data-sly-use.model="com.example.core.models.BrowserStoredList"></sly><div class="browser-stored" data-sly-test="${model.authorPreview && model.browserSource}">
<!-- Display saved items -->
<ul class="browser-stored__list" data-sly-test="${model.hasItems}" data-sly-list.item="${model.items}"> <li> <a href="${item.path @ extension='html'}"> ${item.title} </a> </li> </ul>
<!-- Show empty message when there are no saved items --> <p class="browser-stored__empty" data-sly-test="${!model.hasItems}"> ${model.emptyStateText} </p>
</div>
<!-- Only show the dropzone for authored content -->
<div data-sly-test="${!model.browserSource}" data-sly-resource="${'item-area' @ resourceType='wcm/foundation/components/responsivegrid'}"> </div
If the cookie contains saved items, we'll display them as a list. If it's empty, we'll show the empty message configured in the component's dialog. This way, authors can see their configured message instead of staring at a blank component.
Alright, we've got our authoring experience working. But what happens when someone visits the published page?
We don't want to render a visitor's saved items directly into the initial HTML because that HTML may be cached and served to other visitors. So instead, the published page renders a placeholder. JavaScript reads the browser's cookie, sends the saved IDs to a separate endpoint, and replaces the placeholder with the returned HTML.
I'm keeping the Publish side simplified here because our main focus is getting this component working correctly in the AEM editor. For the following example, we're assuming we've already implemented a selector-based endpoint that validates the requested IDs, looks up the corresponding resources, and returns an HTML fragment. We're also assuming that our Sling Model exposes the endpoint URL and the required component properties.
The important thing is that the initial published response doesn't contain anyone's saved items.
Here's a simplified example,
model.endpointis an assumed getter returning the URL of your separately implemented fragment endpoint.
<div class="browser-stored"
data-sly-test="${model.browserSource && !model.authorPreview}"
data-stored-endpoint="${model.endpoint @ context='uri'}">
<div data-stored-list aria-live="polite">
Loading saved items...
</div>
</div>
Javascript
function readItems() { const raw = document.cookie .split("; ") .find((cookie) => cookie.startsWith("stored_items=")) ?.slice("stored_items=".length);if (!raw) return [];
return raw .split(",") .map((id) => id.trim()) .filter((id) => /^[a-zA-Z0-9/_-]+$/.test(id)) .slice(0, 20); }
async function hydrate(root) { const endpoint = root.dataset.storedEndpoint; if (!endpoint) return;
const items = readItems(); const url = new URL(endpoint, window.location.origin);
url.searchParams.set("items", items.join("|"));
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) return;
const html = await response.text(); const list = root.querySelector("[data-stored-list]");
if (list) list.outerHTML = html; }
First we are reading the saved IDs, passing them to our component, and replacing the placeholder with the returned HTML. If there aren't any saved items, we can display the empty message configured in our dialog instead.
When there aren't any saved items, our endpoint should return the empty-state markup configured in the component dialog. That way, the same placeholder can be replaced with either the saved list or the empty message.
Make sure you call the hydrate method as well.
document
.querySelectorAll("[data-stored-endpoint]")
.forEach((root) => {
hydrate(root);
});Here's something you really don't want to get wrong.
Just because we're loading the saved items through a separate request doesn't automatically mean that response is safe from caching. The endpoint returning our saved-item HTML needs to be excluded from shared caching.
For example, the response should include appropriate headers such as Cache-Control: private, no-store and Dispatcher: no-cache. We also need to verify our Dispatcher cache rules and CDN configuration to make sure those responses aren't being stored.
And don't assume that adding an items query parameter automatically makes the response uncacheable. Dispatcher can be configured to ignore query parameters, which is perfectly fine for ordinary cacheable pages but definitely not what we want here.
We also need to validate the IDs on the server, restrict which repository paths can be queried, and limit how many items a request can process. Remember, anything coming from the browser can be changed by the visitor. That includes cookies and query parameters. The initial published HTML stays shared and cacheable. The personalized response does not.
That's the distinction we need to preserve.
Let’s recap how everything works together:
stored_items cookie.
In the editor, AEM renders everything for us. On publish, JavaScript fills in the content afterward. This gives authors an actual preview of their saved items without interfering with Dispatcher caching.
If you’re running into issues, here are a few things you might encounter.
AEM developer and frontend specialist with expertise in Adobe Experience Manager, React, and modern web development. Passionate about creating accessible, performant web experiences.
Comments
Comments are not yet implemented. This is a placeholder for future comment functionality.