Blocks composition
You don’t have to wait for Adobe to ship a feature you need. Sometimes existing drop-in containers don’t cover it, but you can build it yourself in a custom commerce block using an existing drop-in’s API functions, SDK components, and the event bus.
When to use this pattern
Section titled “When to use this pattern”Use this pattern when:
- No existing container covers the feature.
- An existing drop-in already exports the data you need as an API function.
- You can build the UI from shared SDK components.
| Choose instead | When |
|---|---|
| Extend | A drop-in container already covers the feature, and you only need to change its look, add content, or react to its events. |
| Create | The feature needs its own data model, containers, and events that no installed drop-in exposes, and you’re prepared to maintain a new drop-in package long-term. |
Blocks and drop-ins
Section titled “Blocks and drop-ins”A block is a unit of UI you own — the standard unit of functionality in an Edge Delivery Services page (see Exploring blocks on aem.live). Composing a custom commerce block this way requires fewer resources than creating a drop-in. There’s no new package to publish, version, or maintain, no drop-in SDK dependency to add, and no separate build step — your code is plain JS and CSS that Edge Delivery Services decorates client-side. The tradeoff is that you maintain the composition logic yourself, and it won’t appear in any drop-in’s release notes or upgrade path.
How it works
Section titled “How it works”The following diagram shows how an existing drop-in’s API function, SDK components, and the event bus combine inside a block that uses this pattern.
API functions, SDK components, and the event bus
Section titled “API functions, SDK components, and the event bus”Your storefront already has all three installed. Combine them to build a new feature from existing drop-in artifacts.
Walkthrough: product comparison
Section titled “Walkthrough: product comparison”The product comparison feature needs a table that shows up to three products side by side, plus a persistent tray that collects the products a shopper picks while browsing. No drop-in container covers that feature, but every piece it needs already exists in your storefront:
- Drop-in API function — The Product Discovery drop-in exports a
searchfunction that looks up products by keyword or SKU. - SDK components — Provides the
Button,Icon,Image, andPriceRangeUI primitives this feature needs. - Event bus — Lets the Compare button signal the comparison tray to add a product, without either block importing the other.
The steps below show how this feature can be built as two custom commerce blocks: product-compare for the table and product-compare-bar for the tray.

-
Fetch data using existing API functions. To look up the exact products a shopper picked, call
search()with askufilter:blocks/product-compare/product-compare.js import { search } from '@dropins/storefront-product-discovery/api.js';async function fetchProductsBySkus(skus, searchFilters = []) {const result = await search({ filter: [{ attribute: 'sku', in: skus }, ...searchFilters], pageSize: skus.length },{ scope: 'product-compare-lookup' },);return result?.items ?? [];}The
searchFiltersargument carries the block’s author-configuredfilteroption into this same query, so the SKU lookup enforces the same eligibility rule as the rest of the block. Without it, a shopper could request an ineligible product just by editing the?compare=URL directly. -
Render the UI with SDK components. Mount
ImageandPriceRangefor each product column and aButtonfor the remove action — the same components a drop-in container uses:blocks/product-compare/product-compare.js import {Button, Icon, Image, PriceRange, provider,} from '@dropins/tools/components.js';import { h } from '@dropins/tools/preact.js';const image = product.images[0];await provider.render(Image, {src: image.url, alt: image.label || product.name, loading: 'lazy', params: { width: 400, height: 400 },})(imageWrap);await provider.render(PriceRange, { display: 'from to', minimumAmount, maximumAmount, currency })(priceWrap);await provider.render(Button, {icon: h(Icon, { source: 'Close' }),variant: 'tertiary','aria-label': `Remove ${product.name}`,onClick: () => removeProduct(product.sku),})(removeWrap);Write your block’s own CSS against the same shared design tokens the SDK components use, such as
--spacing-bigand--spacing-medium, so the table and tray don’t look bolted on next to the rest of the page. -
Coordinate blocks with the event bus. The Compare button is often added through a slot on an existing drop-in container, such as the
ProductActionsslot on the product-discoverySearchResultscontainer, rather than in a custom commerce block. See Extend for slot mechanics. Either way, the product list page doesn’t know the comparison tray exists, and the tray doesn’t know which block added a product. They only agree on an event name and a payload shape:blocks/product-list-page/product-list-page.js import { Button, Icon, provider } from '@dropins/tools/components.js';import { events } from '@dropins/tools/event-bus.js';function getCompareButton(product) {const productName = product.name || product.sku;const wrap = document.createElement('div');wrap.className = 'product-discovery-product-actions__compare';provider.render(Button, {icon: Icon({ source: 'Bulk' }),'aria-label': `${labels.Global?.Compare ?? 'Compare'} ${productName}`,variant: 'tertiary',onClick: () => events.emit('compare/products', {sku: product.sku,img: product.images?.[0]?.url ?? '',name: productName,}),})(wrap);return wrap;}// Inside the ProductActions slot callback on SearchResultsconst compareBtn = getCompareButton(ctx.product);actionsWrapper.appendChild(compareBtn);blocks/product-compare-bar/product-compare-bar.js import { events } from '@dropins/tools/event-bus.js';events.on('compare/products', ({ sku, img, name } = {}) => {if (!sku) return;const idx = products.findIndex((p) => p.sku === sku);if (idx !== -1) {products.splice(idx, 1);} else if (products.length < MAX_PRODUCTS) {products.push({ sku, img, name });}render();});Emitting
compare/productsagain for a SKU already in the tray removes it, so the same Compare button can add or remove a product without tracking its own state.
The product-compare-bar tray collecting products a shopper flags for comparison from a product list page -
Store state in the URL. The comparison table’s state — which SKUs to compare — doesn’t need a drop-in or
localStorage. Reading and writing that state to a URL query parameter means shoppers can share or bookmark the comparison page:blocks/product-compare/product-compare.js const skus = (new URLSearchParams(window.location.search).get('compare') ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, MAX_PRODUCTS);// After adding or removing a product:const url = new URL(window.location.href);url.searchParams.set('compare', updatedSkus.join(','));window.history.replaceState({}, '', url);Reloading the page, sharing its URL, or bookmarking it now reproduces the same comparison, since every selection lives in the
?compare=parameter.

Best practices
Section titled “Best practices”- Reuse the drop-in’s existing filters, scopes, and pagination options instead of writing your own data-fetching code, since you’re calling the same function a container calls.
- Follow the
noun/verbconvention already used by drop-in events, such ascart/updatedandsearch/result, so another developer can tell what your custom event does without reading its source. - Design the event so any block with a matching trigger can emit it, not just the one you’re building. A comparison tray listening for
compare/productscan respond to that same event from a product list page, search results, or a carousel, without a separate integration for each. - Before adding
localStorageor a new global variable, check whether the URL or an event’s last payload (events.lastPayload()) already has what you need. - Handle your own loading and error states. A drop-in container already renders a fallback when its data fetch fails, but your block calls the same API function directly, so it needs its own
catchand an empty-results message. - Use this pattern only when no container covers the feature. If a drop-in container already handles most of the feature, extend it with slots or events instead of rebuilding its API function and components yourself.
Hand your plan to AI
Section titled “Hand your plan to AI”AI works best from a plan, not a blank prompt. The plan is the decisions this page walks through: whether to compose, extend, or create, and within a composed block, whether each part reuses a drop-in API function or a drop-in component.
Once that plan is clear, install Boilerplate skills so your AI coding agent can build from it. The skills encode this project’s real conventions, so the agent looks up actual slot names, event payloads, and component props from the drop-in TypeScript definitions instead of inventing new conventions. A plan this concrete makes the generated code cheap enough to test, improve, or throw away.
Next steps
Section titled “Next steps”- Review Extend, substitute, or create? to place this pattern alongside your other options.
- Read the drop-in Functions reference for the full
search()signature and its data models. - Browse the SDK component library to see all available components.
- Read Events for event-naming conventions and the full
events.on()/events.emit()API. - Read Blocks customization for
readBlockConfig()and other block-authoring patterns. - Read Block tables for how a content author places
product-compareorproduct-compare-baron a page — no code change required.