How to Add a Custom Checkout Step in Magento 2
The Luma checkout has two steps: Shipping and Review & Payments. Sometimes a business needs a third one, for example a step to confirm age, to choose a delivery slot, or to collect B2B details before payment. Magento supports this through the step navigator, a small JavaScript model that every checkout step registers with.
This article shows how to add a step in Magento Open Source and Adobe Commerce 2.4.8 with the Luma checkout. It covers the JavaScript component, the template, the layout, the order of steps, and the part tutorials usually skip: how to validate and persist what the step collects.
Think twice before adding a step
Every extra step is one more screen where customers can leave. Before building one, check if the data fits as a field inside an existing step. A single field in the shipping step is less code and less friction. See adding a custom field to checkout for that approach. A new step makes sense when the content is large, has its own rules, or must be completed before the shipping or payment options can be calculated.
How the step navigator works
Magento_Checkout/js/model/step-navigator keeps a list of registered steps. Each step gives it:
- a code, used as the URL hash, for example
#shipping - an alias, which can be null
- a title for the progress bar
- a Knockout observable that controls visibility
- a navigate callback, called when the customer goes back to the step through the progress bar or the hash
- a sort order
The navigator sorts steps by that order, shows one at a time, and moves forward when a step calls stepNavigator.next(). The core shipping and payment components register themselves the same way. Read Magento_Checkout/view/frontend/web/js/view/shipping.js and payment.js in your version to see the sort order they pass, then pick a value that places your step where you want it.
The step component
Create view/frontend/web/js/view/delivery-slot-step.js in your module:
define([
'ko',
'uiComponent',
'underscore',
'Magento_Checkout/js/model/step-navigator'
], function (ko, Component, _, stepNavigator) {
'use strict';
return Component.extend({
defaults: {
template: 'Vendor_DeliverySlot/delivery-slot-step'
},
isVisible: ko.observable(true),
selectedSlot: ko.observable(''),
initialize: function () {
this._super();
stepNavigator.registerStep(
'delivery_slot',
null,
'Delivery Slot',
this.isVisible,
_.bind(this.navigate, this),
15
);
return this;
},
navigate: function () {
this.isVisible(true);
},
navigateToNextStep: function () {
if (!this.selectedSlot()) {
return;
}
stepNavigator.next();
}
});
});
The title passed to registerStep is a plain string here. In a real module wrap it with $t from mage/translate so it can be translated. Sort order 15 is the value Magento's own documentation uses for a step between the default two. Confirm it against the core values in your version as described above.
The template
Create view/frontend/web/template/delivery-slot-step.html. The outer element needs the step code as its id and a fadeVisible binding on the observable you registered:
<li id="delivery_slot" data-bind="fadeVisible: isVisible">
<div class="step-title" data-bind="i18n: 'Delivery Slot'" data-role="title"></div>
<div id="checkout-step-delivery-slot" class="step-content" data-role="content">
<form data-bind="submit: navigateToNextStep" novalidate="novalidate">
<select data-bind="value: selectedSlot">
<option value="" data-bind="i18n: 'Choose a slot'"></option>
<option value="morning" data-bind="i18n: 'Morning'"></option>
<option value="afternoon" data-bind="i18n: 'Afternoon'"></option>
</select>
<div class="actions-toolbar">
<div class="primary">
<button data-role="opc-continue" type="submit" class="button action continue primary">
<span data-bind="i18n: 'Next'"></span>
</button>
</div>
</div>
</form>
</div>
</li>
In a real project the slots would come from the server, either through window.checkoutConfig with a config provider, or from a REST endpoint if they depend on the address. Hardcoded options are only here to keep the example short.
Adding the step to the layout
The component has to be declared as a child of steps in view/frontend/layout/checkout_index_index.xml:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="checkout.root">
<arguments>
<argument name="jsLayout" xsi:type="array">
<item name="components" xsi:type="array">
<item name="checkout" xsi:type="array">
<item name="children" xsi:type="array">
<item name="steps" xsi:type="array">
<item name="children" xsi:type="array">
<item name="delivery-slot-step" xsi:type="array">
<item name="component" xsi:type="string">Vendor_DeliverySlot/js/view/delivery-slot-step</item>
<item name="sortOrder" xsi:type="string">2</item>
<item name="children" xsi:type="array"/>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>
There are two different sort orders. The one in the layout decides the order in which child components render in the DOM. The one passed to registerStep decides the order in the progress bar and the navigation. Keep them consistent, or the progress bar and the page content will disagree.
After adding the files, run bin/magento cache:clean layout full_page. In production mode you also need to deploy static content again so the new JS and template are published. With developer mode and a cleared pub/static/frontend, a hard refresh is enough. The article on UI components and Knockout basics explains how these JSON layouts become components.
Putting the step first
If your step must come before Shipping, there is one extra issue. The shipping component sets itself visible when it initializes, so on page load both steps can appear. The fix documented by Magento is a mixin on Magento_Checkout/js/view/shipping that sets visible to false on initialize when the current hash is not #shipping, and back to true in navigate. Test the page load with and without a hash in the URL, and test the browser back button.
Validating and saving what the step collects
A step that only lives in JavaScript is not enough. The customer can reload, open checkout in another tab or call the API directly. Three rules keep the data reliable:
- Send it to the server before moving on. In
navigateToNextStep, call your own REST endpoint, wait for success, then callstepNavigator.next(). Show the error message if the call fails. UseMagento_Checkout/js/model/full-screen-loaderduring the request. - Store it on the quote. The endpoint should save the value on the quote for the current cart, as a column or in a related table. Use
/V1/carts/mine/...for customers and a guest route with the masked cart id for guests. Creating a custom REST endpoint coverswebapi.xmland the ACL for both. - Validate again when the order is placed. A plugin or observer at order placement should check that the quote has a valid value and refuse the order if not. JavaScript validation is for the customer. Server validation is for the business.
To restore the step after a reload, return the saved value with the checkout config and use it as the initial value of the observable.
Debugging a step that does not appear
When nothing shows, work from the outside in. First check the browser console for RequireJS errors, which usually mean a wrong path in the component or template value. Then check that the layout change is applied: in the console, require('uiRegistry').get('checkout.steps.delivery-slot-step') should return your component. If it returns undefined, the layout XML is not merged, often because of a typo in the nesting or a cache that was not cleaned. If the component exists but the progress bar has no new item, registerStep was not called, so check the initialize method.
Things that break custom steps
- Virtual carts. If the cart has only virtual products, the shipping step is not shown. Make sure your step still makes sense, or hide it in that case by checking
quote.isVirtual()fromMagento_Checkout/js/model/quote. - Third party one step checkouts. Extensions that replace the checkout layout usually ignore extra steps. Check with the vendor before you build.
- Hyvä Checkout. It is built on Magewire and has its own way to add steps, so none of this code applies there.
- Minified or bundled JS. If your step does not show in production but works locally, check that the file is included in the deployed static content and in any bundling configuration.
FAQ
Can I add more than one custom step?
Yes. Each step registers with its own code, observable and sort order. Keep the number low because every step adds friction to checkout.
Why does my new step show together with the shipping step?
Usually the step is placed before Shipping and the shipping component still sets itself visible on load. Add a mixin on the shipping view that hides it until the navigator reaches it.
Where should I save data from a custom step?
On the quote, through your own REST endpoint called before moving to the next step. Copy it to the order at placement and validate it again on the server.
Does this work with Hyvä Checkout?
No. Hyvä Checkout does not use the Luma step navigator or Knockout templates. It has its own step configuration.
I am available for freelance Magento and Adobe Commerce work, including checkout customization. If you need help with this, get in touch.
Related articles
Magento 2 UI Components and Knockout.js: The Basics
A practical intro to Magento 2 UI components and Knockout.js: uiComponent, templates, observables, imports and exports, jsLayout and the uiRegistry.
Add a Custom Field to Magento 2 Checkout and Save It to the Order
Add a custom field to the Magento 2 checkout shipping step, send it with extension attributes, store it on the quote and copy it to the order the right way.
Writing Alpine.js Components in Hyvä: Patterns That Scale
How to write Alpine.js components on Hyvä: passing PHP data, view models, customer data events, form keys, component events, CSP and keeping templates clean.
Hyvä Checkout Explained: Magewire, Customization, Payments
How Hyvä Checkout works for developers: Magewire components, adding a custom field, payment module support, and when to keep the Luma checkout fallback.