Multi-step Checkout Implementation
This tutorial provides a customizable example to implement a comprehensive multi-step checkout in your Adobe Commerce storefront that supports all user scenarios: guest users, logged-in customers, and virtual products.
Overview
This implementation provides a complete multi-step checkout for the Adobe Commerce boilerplate that handles:
- Guest users - Email capture and address entry
- Logged-in customers - Saved address selection and account integration
- Virtual products - Automatic shipping step bypass
- Mixed carts - Physical + virtual product combinations
- Modular architecture - Event-driven step coordination
Implementation Features
Feature | Status |
---|---|
Guest users | ✅ |
Logged-in customers | ✅ |
Virtual products | ✅ |
Mixed carts (physical + virtual products) | ✅ |
Custom payment/shipping methods | 🔧 |
Multi-step Customization
Key areas specific to multi-step checkout customization:
- Step progression logic - Modify
steps.js
for custom user flows and step transitions - Individual step modules - Customize step behavior in
steps/
folder - Step validation - Control when users can advance between steps
- Fragment management - Adapt step-specific HTML fragments in
fragments.js
- Step visibility - Customize CSS classes for active/inactive step states
- Manual synchronization - Control when data is saved to the cart
Architecture
File Structure
The multi-step checkout implementation follows a modular architecture:
File | Purpose | Key Features |
---|---|---|
commerce-checkout-multi-step.js | Entry point and block decorator | Initializes the checkout system |
commerce-checkout-multi-step.css | Step styling and visibility controls | Step progression, visual states, responsive design |
steps.js | Main implementation | Step coordination and state management |
steps/shipping.js | Shipping/contact step logic | Login detection, address forms, email validation |
steps/shipping-methods.js | Delivery method selection | Shipping options, cost calculation |
steps/payment-methods.js | Payment method selection | Payment provider integration |
steps/billing-address.js | Billing address step | Conditional billing form rendering |
fragments.js | HTML fragment creation | Step-specific DOM structure generation |
containers.js | Container rendering functions | Drop-in container management |
components.js | UI component functions | Reusable UI elements |
utils.js | Utility functions and helpers | Virtual cart detection, validation |
constants.js | Shared constants and configuration | CSS classes, form names, storage keys |
Manual Synchronization Control
In multi-step checkout, containers use autoSync: false
to disable automatic backend synchronization, allowing manual control over when data is saved:
// Containers with manual sync controlconst containers = [ 'LoginForm', // Manual email/authentication handling 'ShippingMethods', // Manual shipping method selection 'PaymentMethods', // Manual payment method selection 'BillToShippingAddress' // Manual billing address control];
// Example: ShippingMethods with manual syncCheckoutProvider.render(ShippingMethods, { UIComponentType: 'ToggleButton', autoSync: false, // Disable automatic cart updates})(container);
AutoSync behavior:
autoSync: true
(default) - Local changes automatically sync with backend via GraphQL mutationsautoSync: false
- Changes maintained locally only, no automatic API calls
Why disable autoSync in multi-step:
- Controlled timing - Save data only when step is completed and validated
- Better UX - Prevent partial/invalid data from being sent to cart
- Step coordination - Parent step manager controls when to persist data
- Validation first - Ensure all step requirements met before saving
Manual sync example:
// Step completion with manual sync (triggered by continue button)const continueFromStep = async () => { if (!validateStepData()) return;
// Manual API call with error handling try { await checkoutApi.setShippingMethodsOnCart([{ carrier_code: selectedMethod.carrier.code, method_code: selectedMethod.code, }]); } catch (error) { console.error('Failed to save step data:', error); return; // Don't proceed if API call fails }
// Only continue if API call succeeded await displayStepSummary(selectedMethod); await continueToNextStep();
events.emit('checkout/step/completed', null);};
Key patterns:
- Continue button trigger - API calls happen when user clicks continue, not on selection
- Try-catch wrapping - All API calls must be wrapped for error handling
- Early return on error - If API fails, don’t proceed to next step
- Success-only progression - Only move forward if data successfully saved
This approach ensures data integrity and provides smooth step transitions without premature backend updates.
API Reference
Step modules rely on the checkout dropin’s API functions for cart management. The complete API reference is available in the Checkout functions documentation.
Key APIs for multi-step implementation:
Function | Purpose | Used In Step |
---|---|---|
setGuestEmailOnCart() | Set guest user email | Shipping (email capture) |
setShippingAddress() | Set shipping address on cart | Shipping (address collection) |
setShippingMethodsOnCart() | Set shipping methods on cart | Shipping Methods |
setPaymentMethod() | Set payment method on cart | Payment Methods |
setBillingAddress() | Set billing address on cart | Payment Methods, Billing Address |
isEmailAvailable() | Check email availability | Order Header (account creation) |
getStoreConfigCache() | Get cached store configuration | Address forms (default country) |
estimateShippingMethods() | Estimate shipping costs | Address forms (cost calculation) |
All step completion logic should use these APIs with proper error handling as shown in the manual sync examples above.
Note: The implementation uses event-driven data (events.lastPayload()
) instead of direct getCart()
or getCustomer()
calls for performance optimization and real-time state management.
Component Registry Pattern
The components.js
file implements a registry system specifically for SDK components and external UI library components:
// components.js - Component registry (separate from containers)import { Button, Header, ProgressSpinner, provider as UI } from '@dropins/tools/components.js';
const registry = new Map();
// Component IDs for UI elementsexport const COMPONENT_IDS = { CHECKOUT_HEADER: 'checkoutHeader', SHIPPING_STEP_CONTINUE_BTN: 'shippingStepContinueBtn', PAYMENT_STEP_TITLE: 'paymentStepTitle', // ... more component IDs};
// Core component methodsexport const hasComponent = (id) => registry.has(id);export const removeComponent = (id) => { const component = registry.get(id); if (component) { component.remove(); registry.delete(id); }};
// Render SDK componentsexport const renderCheckoutHeader = (container) => renderComponent( COMPONENT_IDS.CHECKOUT_HEADER, async () => UI.render(Header, { className: 'checkout-header', level: 1, size: 'large', title: 'Checkout', })(container));
export const renderStepContinueBtn = async (container, stepId, onClick) => renderPrimaryButton(container, stepId, { children: 'Continue', onClick });
Key distinction from containers:
containers.js
- Manages dropin containers (LoginForm, AddressForm, ShippingMethods, etc.)components.js
- Manages SDK/UI library components (Button, Header, ProgressSpinner, etc.)
Usage guidelines:
- Use
components.js
for: Headers, buttons, spinners, modals, and other UI elements from the SDK - Use
containers.js
for: Checkout dropins, account dropins, cart dropins, and other business logic containers - Recommended approach: Keep dropin containers and UI components in separate registries for better organization
This ensures clean architecture where components.js
handles pure UI elements while containers.js
manages complex business logic containers.
Container Management
The containers.js
file provides a complete system for managing dropin containers (LoginForm, AddressForm, ShippingMethods, etc.) with registry-based lifecycle management.
Registry System:
// containers.js - Registry system for dropin containersconst registry = new Map();
// Core registry methodsexport const hasContainer = (id) => registry.has(id);export const getContainer = (id) => registry.get(id);export const unmountContainer = (id) => { if (!registry.has(id)) return; const containerApi = registry.get(id); containerApi.remove(); registry.delete(id);};
// Helper to render or get existing containerconst renderContainer = async (id, renderFn) => { if (registry.has(id)) { return registry.get(id); // Return existing }
const container = await renderFn(); // Render new registry.set(id, container); return container;};
Container IDs and render functions:
Each container is identified by a unique string ID and has a corresponding render function that handles the registry logic:
// Predefined container identifiersexport const CONTAINERS = Object.freeze({ LOGIN_FORM: 'loginForm', SHIPPING_ADDRESS_FORM: 'shippingAddressForm', SHIPPING_METHODS: 'shippingMethods', PAYMENT_METHODS: 'paymentMethods', // ... more containers});
// Usage in container functionsexport const renderLoginForm = async (container) => renderContainer( CONTAINERS.LOGIN_FORM, async () => CheckoutProvider.render(LoginForm, { /* config */ })(container));
Key benefits of the container system:
- Centralized logic - Complex container configuration in one place
- Prevents duplicates - Registry ensures same container isn’t rendered multiple times
- Memory management - Automatic cleanup prevents memory leaks
- State preservation - Containers maintain state across step transitions
Registry lifecycle:
- Check existing -
hasContainer()
/getContainer()
to find existing instances - Render once -
renderContainer()
creates new containers only if needed - Cleanup -
unmountContainer()
removes containers and clears references
This comprehensive container management approach ensures efficient resource usage and prevents common issues like duplicate event listeners or memory leaks.
Step Modules
The steps/
folder contains individual step modules that handle specific checkout phases. Each step module implements a consistent interface and manages its own domain logic, UI rendering, and data validation.
Step module structure:
Each step file in the steps/
folder follows the same architectural pattern:
// steps/shipping.js - Example step moduleexport const createShippingStep = ({ getElement, api, events, ui }) => { return { async display(data) { // Render step UI using containers (LoginForm, AddressForm) // Handle different user types (guest vs logged-in) // Use manual sync patterns for form data },
async displaySummary(data) { // Show completed step summary using fragment functions // Create edit functionality for step modifications },
async continue() { // Validate step data and make API calls // Handle step progression logic // Emit completion events },
isComplete(data) { // Validate step completion based on cart data // Handle virtual product logic },
isActive() { // Check if step is currently active } };};
Available step modules:
shipping.js
- Handles email capture (LoginForm) and shipping address collection (AddressForm)shipping-methods.js
- Manages delivery method selection and shipping cost calculationpayment-methods.js
- Handles payment provider integration and method selectionbilling-address.js
- Manages conditional billing address form rendering
Step module responsibilities:
- UI rendering - Uses container functions to render dropins
- Data validation - Validates step completion
- API integration - Makes manual API calls with error handling
- Event handling - Responds to checkout events
- Summary creation - Generates read-only summaries with edit functionality
Fragment Management
The fragments.js
file is responsible for creating all DOM structure in the multi-step checkout. It provides a centralized system for generating HTML fragments, managing selectors, and creating reusable summary components.
Core responsibilities:
- DOM Structure Creation - Generates HTML fragments for each step and the main checkout layout
- Selector Management - Centralizes all CSS selectors in a frozen object for consistency
- Summary Components - Provides reusable functions for creating step summaries with edit functionality
- Utility Functions - Helper functions for fragment creation and DOM querying
Fragment Creation Pattern:
// Step-specific fragment creationfunction createShippingStepFragment() { return createFragment(` <div class="checkout__login ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT}"></div> <div class="checkout__login-form-summary ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_SUMMARY}"></div> <div class="checkout__shipping-form ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT}"></div> <div class="checkout__shipping-form-summary ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_SUMMARY}"></div> <div class="checkout__continue-to-shipping-methods ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT} ${CHECKOUT_STEP_BUTTON}"></div> `);}
// Main checkout structureexport function createCheckoutFragment() { const checkoutFragment = createFragment(` <div class="checkout__wrapper"> <div class="checkout__loader"></div> <div class="checkout__content"> <div class="checkout__main"></div> <div class="checkout__aside"></div> </div> </div> `); // Append step fragments to main structure return checkoutFragment;}
Centralized Selector System:
// All selectors defined in one placeexport const selectors = Object.freeze({ checkout: { loginForm: '.checkout__login', shippingAddressForm: '.checkout__shipping-form', shippingStepContinueBtn: '.checkout__continue-to-shipping-methods', // ... more selectors }});
Summary Creation Functions:
// Reusable summary components with edit functionalityexport const createLoginFormSummary = (email, onEditClick) => { const content = document.createElement('div'); content.textContent = email; return createSummary(content, onEditClick);};
export const createAddressSummary = (data, onEditClick) => { // Format address data into summary display return createSummary(formattedContent, onEditClick);};
Key benefits of fragment management:
- Consistent DOM structure - All HTML is generated through standardized functions
- CSS class coordination - Selectors and fragments use the same class names
- Reusable components - Summary functions can be used across different steps
- Maintainable markup - All HTML structure defined in one centralized location
Element Access Pattern
Step modules access DOM elements using the centralized selector system from fragments.js
. Here’s how step modules import and use those selectors:
// steps/shipping.js - Element access in step modulesimport { selectors } from '../fragments.js';
const { checkout } = selectors;
const elements = { $loginForm: getElement(checkout.loginForm), $loginFormSummary: getElement(checkout.loginFormSummary), $shippingAddressForm: getElement(checkout.shippingAddressForm), $shippingAddressFormSummary: getElement(checkout.shippingAddressFormSummary), $shippingStep: getElement(checkout.shippingStep), $shippingStepContinueBtn: getElement(checkout.shippingStepContinueBtn),};
Key benefits of this pattern:
- Centralized selectors - All CSS classes defined in one location
- Type safety - Object structure prevents typos and missing selectors
- Maintainability - Easy to update selectors across the entire system
- Consistency - All step modules follow the same element access pattern
- Fragment coordination - Selectors match the structure created by fragments
This ensures that fragments create the DOM structure and steps access it through a consistent, maintainable selector system.
Summary and Edit Pattern
When users complete a step by clicking the continue button and validation succeeds, the step transitions to summary mode:
// Step completion flowasync function continueFromStep() { // 1. Validate step data if (!validateStep()) return;
// 2. Save data to cart await api.setStepData(formData);
// 3. Hide step content, show summary await displayStepSummary(data);
// 4. Move to next step await displayNextStep();}
Summary features:
- Read-only display - Shows completed step information in condensed format
- Edit functionality - “Edit” link allows users to return and modify data
- Visual state - Different styling indicates step completion
- Persistent data - Summary reflects the actual saved cart data
Edit flow:
// Edit button functionalityconst handleEdit = async () => { await displayStep(true); // Reactivate step // Previous data automatically pre-fills forms};
This pattern ensures users can review their choices and make changes at any point without losing progress.
Place Order Button Enablement
The Place Order button is disabled by default and only becomes enabled when all required steps are completed:
// Place order button managementasync function updatePlaceOrderButton(data) { const allStepsComplete = steps.shipping.isComplete(data) && (!isVirtualCart(data) ? steps.shippingMethods.isComplete(data) : true) && steps.paymentMethods.isComplete(data) && steps.billingAddress.isComplete(data);
if (allStepsComplete) { placeOrderButton.setProps({ disabled: false }); } else { placeOrderButton.setProps({ disabled: true }); }}
Progressive enablement features:
- Disabled by default - Prevents incomplete order submissions
- Step validation - Checks each step’s completion status
- Virtual product logic - Skips shipping validation for virtual carts
- Real-time updates - Button state updates as users complete steps
- Visual feedback - Users can see their progress toward completion
Implementation Guide
The following sections demonstrate how to build a production-ready multi-step checkout using Adobe’s drop-in components. This implementation replaces the regular one-step checkout in the boilerplate template with a sophisticated, modular system.
Create the entry point and main structure
Create the main block file commerce-checkout.js
and set up the modular architecture:
// Initializersimport '../../scripts/initializers/account.js';import '../../scripts/initializers/checkout.js';import '../../scripts/initializers/order.js';
// Block-level utilsimport { setMetaTags } from './utils.js';
// Fragmentsimport { createCheckoutFragment } from './fragments.js';import createStepsManager from './steps.js';
export default async function decorate(block) { setMetaTags('Checkout'); document.title = 'Checkout';
block.replaceChildren(createCheckoutFragment());
const stepsManager = createStepsManager(block); await stepsManager.init();}
Create fragments.js
for the main HTML structure:
export function createCheckoutFragment() { return document.createRange().createContextualFragment(` <div class="checkout__wrapper"> <div class="checkout__heading"></div> <div class="checkout__empty-cart"></div> <div class="checkout__content"> <div class="checkout__main"></div> <div class="checkout__aside"></div> </div> </div> `);}
This modular approach separates concerns: the entry point coordinates everything, fragments handle HTML creation, and the steps manager handles step logic.
Step fragments and HTML structure
Create the step-specific fragments in fragments.js
. Each checkout step gets its own fragment with specific containers and CSS classes:
import { CHECKOUT_BLOCK, CHECKOUT_STEP_BUTTON, CHECKOUT_STEP_CONTENT, CHECKOUT_STEP_SUMMARY, CHECKOUT_STEP_TITLE,} from './constants.js';
/** * Creates the shipping address fragment for the checkout. * Includes login form and address form containers. */function createShippingStepFragment() { return document.createRange().createContextualFragment(` <div class="checkout__login ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT}"></div> <div class="checkout__login-form-summary ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_SUMMARY}"></div> <div class="checkout__shipping-form ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT}"></div> <div class="checkout__shipping-form-summary ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_SUMMARY}"></div> <div class="checkout__continue-to-shipping-methods ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT} ${CHECKOUT_STEP_BUTTON}"></div> `);}
/** * Creates the shipping methods fragment for the checkout. */function createShippingMethodsStepFragment() { return document.createRange().createContextualFragment(` <div class="checkout__shipping-methods-title ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_TITLE}"></div> <div class="checkout__shipping-methods-list ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT}"></div> <div class="checkout__shipping-methods-summary ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_SUMMARY}"></div> <div class="checkout__continue-to-payment ${CHECKOUT_BLOCK} ${CHECKOUT_STEP_CONTENT} ${CHECKOUT_STEP_BUTTON}"></div> `);}
Key fragment concepts:
- CHECKOUT_STEP_CONTENT - Shows containers when step is active (editable mode)
- CHECKOUT_STEP_SUMMARY - Shows completed step information (read-only mode)
- CHECKOUT_STEP_BUTTON - Continue buttons for step progression
- Multiple containers per step - Each fragment can contain multiple containers with their own summary versions
- CSS-driven visibility - No DOM manipulation, just class-based show/hide
The shipping step includes both login and address containers because guests need both email capture (via LoginForm) and shipping address entry (via AddressForm).
Create step modules
Create individual step modules that implement the universal step interface. Each step module follows the pattern described in the Step Modules architecture section.
Required step files:
steps/shipping.js
- Email capture (LoginForm) and shipping address collection (AddressForm)steps/shipping-methods.js
- Delivery method selection and cost calculationsteps/payment-methods.js
- Payment provider integration and method selectionsteps/billing-address.js
- Conditional billing address form rendering
Implementation reference:
For complete implementations of these step modules, see the sample files in the demo repository. Each file demonstrates the full step interface implementation with proper error handling, user flow logic, and integration with containers and APIs.
Implement the steps manager
Create steps.js
to coordinate all step logic and manage the checkout flow. Build this step by step:
-
Set up the basic structure with imports and function signature:
import { createShippingStep } from './steps/shipping.js';import { createShippingMethodsStep } from './steps/shipping-methods.js';import { createPaymentMethodsStep } from './steps/payment-methods.js';import { createBillingAddressStep } from './steps/billing-address.js';export default function createStepsManager(block) {// Implementation will go here} -
Create step instances by gathering dependencies and instantiating each step module:
export default function createStepsManager(block) {const elements = getElements(block);const dependencies = { elements, api, events, ui };const steps = {shipping: createShippingStep(dependencies),shippingMethods: createShippingMethodsStep(dependencies),paymentMethods: createPaymentMethodsStep(dependencies),billingAddress: createBillingAddressStep(dependencies)};} -
Implement step coordination logic that determines which step to show based on completion status:
async function handleCheckoutUpdated(data) {// Step 1: Shipping - always requiredif (!steps.shipping.isComplete(data)) {await steps.shipping.display(data);return;}await steps.shipping.displaySummary(data);// Step 2: Shipping Methods (skip for virtual products)if (!isVirtualCart(data)) {if (!steps.shippingMethods.isComplete(data)) {await steps.shippingMethods.display(data);return;}await steps.shippingMethods.displaySummary(data);}// Step 3: Payment Methodsif (!steps.paymentMethods.isComplete(data)) {await steps.paymentMethods.display(data);return;}await steps.paymentMethods.displaySummary(data);// Step 4: Billing Address (if needed)if (!steps.billingAddress.isComplete(data)) {await steps.billingAddress.display(data);return;}await steps.billingAddress.displaySummary(data);} -
Wire up event handling to respond to checkout state changes:
return {async init() {events.on('checkout/initialized', handleCheckoutUpdated);events.on('checkout/updated', handleCheckoutUpdated);}};
The steps manager uses the early return pattern - if a step is incomplete, it displays that step and exits. Only when all previous steps are complete does it move to the next step. This ensures proper linear progression through the checkout flow.
Add CSS styling for step controls
Create the CSS that controls step visibility and progression. Add this to your commerce-checkout-multi-step.css
file:
-
Step visibility controls - Define the core classes that show/hide step content:
/* Hide all step content by default */.checkout-step-content {display: none;}/* Show content when step is active */.checkout-step-active .checkout-step-content {display: block;}/* Hide summaries by default */.checkout-step-summary {display: none;}/* Show summaries when step is completed (not active) */.checkout-step:not(.checkout-step-active) .checkout-step-summary {display: block;}/* Hide continue buttons when step is completed */.checkout-step:not(.checkout-step-active) .checkout-step-button {display: none;} -
Step progression styling - For complete visual styling (borders, colors, animations, etc.), see
commerce-checkout-multi-step.css
in the demo repository.
These CSS rules create the core multi-step behavior: content shows when active, summaries show when completed, and step progression controls guide users through the checkout flow.
Example
See blocks/commerce-checkout-multi-step
in the demos
branch of the boilerplate repository for complete JS and CSS code for the multi-step checkout flow.