Skip to content

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

FeatureStatus
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:

FilePurposeKey Features
commerce-checkout-multi-step.jsEntry point and block decoratorInitializes the checkout system
commerce-checkout-multi-step.cssStep styling and visibility controlsStep progression, visual states, responsive design
steps.jsMain implementationStep coordination and state management
steps/shipping.jsShipping/contact step logicLogin detection, address forms, email validation
steps/shipping-methods.jsDelivery method selectionShipping options, cost calculation
steps/payment-methods.jsPayment method selectionPayment provider integration
steps/billing-address.jsBilling address stepConditional billing form rendering
fragments.jsHTML fragment creationStep-specific DOM structure generation
containers.jsContainer rendering functionsDrop-in container management
components.jsUI component functionsReusable UI elements
utils.jsUtility functions and helpersVirtual cart detection, validation
constants.jsShared constants and configurationCSS 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 control
const 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 sync
CheckoutProvider.render(ShippingMethods, {
UIComponentType: 'ToggleButton',
autoSync: false, // Disable automatic cart updates
})(container);

AutoSync behavior:

  • autoSync: true (default) - Local changes automatically sync with backend via GraphQL mutations
  • autoSync: 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:

FunctionPurposeUsed In Step
setGuestEmailOnCart()Set guest user emailShipping (email capture)
setShippingAddress()Set shipping address on cartShipping (address collection)
setShippingMethodsOnCart()Set shipping methods on cartShipping Methods
setPaymentMethod()Set payment method on cartPayment Methods
setBillingAddress()Set billing address on cartPayment Methods, Billing Address
isEmailAvailable()Check email availabilityOrder Header (account creation)
getStoreConfigCache()Get cached store configurationAddress forms (default country)
estimateShippingMethods()Estimate shipping costsAddress 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 elements
export const COMPONENT_IDS = {
CHECKOUT_HEADER: 'checkoutHeader',
SHIPPING_STEP_CONTINUE_BTN: 'shippingStepContinueBtn',
PAYMENT_STEP_TITLE: 'paymentStepTitle',
// ... more component IDs
};
// Core component methods
export const hasComponent = (id) => registry.has(id);
export const removeComponent = (id) => {
const component = registry.get(id);
if (component) {
component.remove();
registry.delete(id);
}
};
// Render SDK components
export 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 containers
const registry = new Map();
// Core registry methods
export 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 container
const 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 identifiers
export const CONTAINERS = Object.freeze({
LOGIN_FORM: 'loginForm',
SHIPPING_ADDRESS_FORM: 'shippingAddressForm',
SHIPPING_METHODS: 'shippingMethods',
PAYMENT_METHODS: 'paymentMethods',
// ... more containers
});
// Usage in container functions
export 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:

  1. Check existing - hasContainer() / getContainer() to find existing instances
  2. Render once - renderContainer() creates new containers only if needed
  3. 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 module
export 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 calculation
  • payment-methods.js - Handles payment provider integration and method selection
  • billing-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 creation
function 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 structure
export 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 place
export 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 functionality
export 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 modules
import { 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 flow
async 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 functionality
const 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 management
async 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:

// Initializers
import '../../scripts/initializers/account.js';
import '../../scripts/initializers/checkout.js';
import '../../scripts/initializers/order.js';
// Block-level utils
import { setMetaTags } from './utils.js';
// Fragments
import { 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 calculation
  • steps/payment-methods.js - Payment provider integration and method selection
  • steps/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:

  1. 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
    }
  2. 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)
    };
    }
  3. Implement step coordination logic that determines which step to show based on completion status:

    async function handleCheckoutUpdated(data) {
    // Step 1: Shipping - always required
    if (!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 Methods
    if (!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);
    }
  4. 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:

  1. 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;
    }
  2. 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.