Building Your Commerce App
Use this guide to build your Commerce App. Choose the architecture that matches your domain — UI-only, Backend-only, or Fullstack — and follow the guidance below.
Getting Started with Claude Code
The Commerce Apps repository includes Claude Code skills to accelerate development. Start with /scaffold-app to generate a complete app directory structure with templates for your chosen architecture (UI-only, Backend-only, or Fullstack). The scaffold skill prompts for your app’s domain, name, and ISV details, then generates all required files including commerce-app.json, tasksList.json, and starter IMPEX files.
For IMPEX generation (/generate-service-impex, /generate-site-preferences-impex, /generate-custom-object-impex) and incremental IMPEX validation (/validate-impex), see the full catalog in Development Environment Setup — Claude Code Skills.
Backend-Only Apps
Backend-only apps implement platform-defined extension point hooks. See The Three Architectures for an overview.
Extension Points
If your domain has a platform-defined extension point (currently Tax, with additional domains later in 2026), your backend-only or fullstack app will include hook implementations that fulfill the interface contract. Each extension point defines a contract: when the platform calls your code, what data it passes, and what response it expects. Your cartridge provides script implementations for these hooks.
For example, for the Tax domain, you implement:
sfcc.app.tax.calculate: Receives adw.order.LineItemCtnr(basket). Called on every basket operation (adding items, changing quantities, updating shipping address, and so on), not only at checkout. Returnsdw.system.Statusornullfor success. Both returningStatus.ERRORand throwing an exception always block the basket operation — there is no non-blocking error path for this hook.sfcc.app.tax.commit: Receives adw.order.Order(statusCREATED). Called immediately after successful order creation. Returnsdw.system.Statusornullfor success. ReturningStatus.ERRORis non-blocking (logged as a warning, order proceeds); throwing an exception is blocking (rolls back order creation).sfcc.app.tax.cancel: Receives adw.order.Order. Called duringOrderMgr.failOrder()orOrderMgr.cancelOrder(). Returnsdw.system.Statusornullfor success. ReturningStatus.ERRORis non-blocking (logged as a warning, cancellation proceeds); throwing an exception is blocking (rolls back the cancellation).
Unlike legacy hooks (where all integrations share dw.order.calculateTax and conflict), these domain-specific extension points are resolved through the Extension Registry, not cartridge path scanning. Only the registered provider for a given domain is called.
Important: App-specific extension points (for example,
sfcc.app.tax.*) can only be invoked through the Commerce Apps installation process. You can’t implement these hooks outside of a Commerce App — the platform gates their invocation on the app being installed and its feature toggles being enabled.
Reference: The
sfcc.app.tax.*extension points will be included in the 26.6 Script API documentation with full contract details, parameter types, and return value specifications. Any new extension points introduced for Commerce Apps in future releases will also be documented there.
Example hooks.json structure:
1{
2 "hooks": [
3 {
4 "name": "sfcc.app.tax.calculate",
5 "script": "./hooks/taxCalculate"
6 },
7 {
8 "name": "sfcc.app.tax.commit",
9 "script": "./hooks/taxCommit"
10 },
11 {
12 "name": "sfcc.app.tax.cancel",
13 "script": "./hooks/taxCancel"
14 }
15 ]
16}Important — Function naming convention: The exported JavaScript function name in each hook script must match the last segment of the extension point name. For example, the script referenced by
sfcc.app.tax.calculatemust export a function namedcalculate; the script forsfcc.app.tax.commitmust exportcommit; and so on.1// hooks/taxCalculate.js 2exports.calculate = function(basket) { ... };
Important — SiteGenesis and SFRA cartridges are incompatible with Commerce Apps: SiteGenesis (
sitegenesis_storefront_controllers,sitegenesis_storefront_core) and SFRA (app_storefront_base) cartridges override thedw.order.calculatehook, which bypasses the platform’s Commerce App tax hook selection entirely. These cartridges must be removed from your site’s cartridge path before installing or running a Commerce App. Commerce Apps (CAP) are designed for use with Storefront Next and aren’t compatible with SiteGenesis or SFRA storefronts.
Example: non-blocking error (returning Status.ERROR)
For most hooks, returning Status.ERROR is non-blocking — the platform logs a warning and allows the operation to continue. Use this approach when the failure should not prevent the shopper from completing their action. Note that some hooks (such as sfcc.app.tax.calculate) block regardless of whether you return Status.ERROR or throw an exception — check the extension point contract for your domain.
1"use strict";
2
3var Status = require("dw/system/Status");
4var Logger = require("dw/system/Logger");
5
6var log = Logger.getLogger("myapp", "hooks");
7
8exports.commit = function (order) {
9 var result = callExternalProvider(order);
10
11 if (!result.success) {
12 // Non-blocking: the platform logs this as a warning and the operation proceeds.
13 log.warn("Provider returned error for order {0}: {1}", order.orderNo, result.errorMessage);
14 return new Status(Status.ERROR, "PROVIDER_CALL_FAILED", "External provider returned an error");
15 }
16
17 return new Status(Status.OK);
18};Example: blocking error (throwing an exception)
When your hook throws an exception, the platform blocks the operation and rolls back the transaction. Use this only when the operation must not proceed.
1"use strict";
2
3var Status = require("dw/system/Status");
4var Logger = require("dw/system/Logger");
5
6var log = Logger.getLogger("myapp", "hooks");
7
8exports.commit = function (order) {
9 var result = callExternalProvider(order);
10
11 if (!result.success) {
12 // Blocking: throw to prevent the operation from proceeding.
13 log.error("Provider call failed for order {0}: {1}", order.orderNo, result.errorMessage);
14 throw new Error("Provider call failed: " + result.errorMessage);
15 }
16
17 return new Status(Status.OK);
18};Platform behavior: The basket operation (or order creation, or cancellation) is rolled back. The shopper receives a 500 error response.
Implementation best practices
Service timeout handling
Extension point hooks run synchronously within the shopper request. If your hook calls an external API, the service timeout directly impacts shopper-facing latency. Use the Commerce Cloud Service Framework (dw.svc) with a service profile that defines explicit timeouts, rate limits, and circuit breakers.
Recommended service profile settings for checkout-path hooks:
1<service-profile profile-id="myapp.provider.profile">
2 <timeout-millis>5000</timeout-millis>
3 <rate-limit-enabled>true</rate-limit-enabled>
4 <rate-limit-calls>100</rate-limit-calls>
5 <rate-limit-millis>1000</rate-limit-millis>
6 <circuit-breaker-enabled>true</circuit-breaker-enabled>
7 <circuit-breaker-max-calls>3</circuit-breaker-max-calls>
8</service-profile>timeout-millis: Keep at 5000 ms or less for real-time checkout hooks. A 10-second timeout will cause unacceptable shopper latency.- Circuit breaker: Enable with a low threshold (
max-calls: 3) so the platform fails fast if your provider is down, rather than making shoppers wait through repeated timeouts. - Rate limiting: Protect your provider from burst traffic during flash sales or load tests.
When a service call times out or the circuit breaker trips, the service framework returns a Result with status !== 'OK'. Handle this in your hook:
1var result = myService.call(requestPayload);
2
3if (result.status !== "OK") {
4 log.error("Service call failed: {0} (error: {1})", result.errorMessage, result.error);
5 // Decide based on your extension point contract whether to throw or return Status.ERROR
6}Logging best practices
Use dw.system.Logger with a consistent category and prefix for all log messages. This makes it straightforward to filter your app’s logs in Log Center.
1var Logger = require("dw/system/Logger");
2var log = Logger.getLogger("myapp", "hooks");log.debug()— verbose details during development (request/response payloads)log.info()— key lifecycle events (hook invoked, service call succeeded)log.warn()— recoverable issues (returningStatus.ERROR, fallback behavior)log.error()— unrecoverable failures (throwing an exception, data integrity issues)
Always include identifiers in log messages so issues can be traced:
1log.info(
2 "Hook called for basket {0}, shipment count: {1}",
3 lineItemCtnr.getUUID(),
4 lineItemCtnr.getShipments().size(),
5);
6
7log.error("Hook failed for order {0}: {1}", order.orderNo, result.errorMessage);Tip: Enable
communication-logon your service definition during development to capture full HTTP request/response details in Log Center. Disable it in production to avoid logging sensitive data.
Transaction ID tracking
If your external provider assigns a transaction ID, persist it on the order so subsequent hooks can reference the same transaction. Use order-level custom attributes for this:
1// In your first hook — store the transaction ID from your provider's response
2var Transaction = require("dw/system/Transaction");
3
4Transaction.wrap(function () {
5 lineItemCtnr.custom.myAppTransactionId = providerResponse.transactionId;
6});1// In a later hook — reference the same transaction
2var transactionId = order.custom.myAppTransactionId;
3if (!transactionId) {
4 log.warn("No transaction ID found on order {0}, skipping commit", order.orderNo);
5 return new Status(Status.ERROR, "NO_TRANSACTION_ID", "Missing transaction ID");
6}
7callExternalProvider(transactionId, order);Define the custom attribute in your IMPEX under meta/system-objecttype-extensions.xml:
1<type-extension type-id="Order">
2 <custom-attribute-definitions>
3 <attribute-definition attribute-id="myAppTransactionId">
4 <display-name xml:lang="x-default">Provider Transaction ID</display-name>
5 <type>string</type>
6 <mandatory-flag>false</mandatory-flag>
7 <externally-managed-flag>true</externally-managed-flag>
8 </attribute-definition>
9 </custom-attribute-definitions>
10</type-extension>Naming convention: Prefix all custom attribute IDs with your app name (for example,
myApp) to avoid collisions with other Commerce Apps or merchant customizations.
Custom attribute usage patterns
Beyond transaction IDs, Commerce Apps commonly use custom attributes for:
- Basket-level details: Store intermediate calculation results so your hook can detect when a recalculation is actually needed (for example, by comparing a hash of line items and addresses).
- Order-level audit data: Store the provider’s response code, document status, or error details for post-order reconciliation.
- Site preferences: Store merchant-configurable values like API mode (sandbox vs. production), default codes, or enable/disable flags.
Use externally-managed-flag set to true for attributes that should not appear in Business Manager’s attribute editor (internal tracking fields). Set it to false for attributes merchants need to see or edit.
Optional: delivering Storefront Next components
Backend-only apps that implement extension points can optionally include Storefront Next extensions to provide custom UI beyond what the platform components offer. For example, Avalara provides an avatax-tax-breakdown extension that displays a detailed tax breakdown not covered by the standard tax line item component.
Connection Health Check
Any Commerce App — UI-only, Backend-only, or Fullstack — can implement an optional connection health check hook so Business Manager displays connectivity status on the app’s tile in the Cart & Checkout Hub. The platform calls the hook when a merchant loads the app details page or manually refreshes the connection status. How you define “connection” is up to you: an API ping, a credential validation, a third-party service check, or any verification that confirms your app’s external dependency is reachable.
Registering the hook
Add a checkConnectionHealth entry to your cartridge’s hooks.json:
1{
2 "hooks": [
3 {
4 "name": "sfcc.app.<domain>.checkConnectionHealth",
5 "script": "./hooks/checkConnectionHealth.js"
6 }
7 ]
8}Replace <domain> with your app’s domain (for example, tax, shipping, payment, ratings-and-reviews). The hook name must follow the sfcc.app.<domain>.checkConnectionHealth convention — the platform uses it to associate the hook with your app’s installation.
Function naming convention: The exported function must be named
checkConnectionHealth, matching the last segment of the extension point name.
Implementing the hook
The hook must export a checkConnectionHealth function that returns a dw.system.Status. Use Status.OK for healthy, Status.ERROR with code DEGRADED for partial impairment, and Status.ERROR with code UNHEALTHY (or any other error code) for failures.
1"use strict";
2
3var Status = require("dw/system/Status");
4
5exports.checkConnectionHealth = function () {
6 var myService = require("*/cartridge/scripts/services/myService");
7
8 try {
9 var result = myService.call("GET", "/health", null);
10
11 if (result.success) {
12 var status = new Status(Status.OK, "HEALTHY");
13 status.addDetail("message", "Service responded in " + result.latency + "ms");
14 return status;
15 }
16
17 var unhealthy = new Status(Status.ERROR, "UNHEALTHY");
18 unhealthy.addDetail("message", "Unable to reach service");
19 unhealthy.addDetail(
20 "remediation",
21 "Verify credentials in Administration > Operations > Services > myvendor.api",
22 );
23 return unhealthy;
24 } catch (e) {
25 var errorStatus = new Status(Status.ERROR, "UNHEALTHY");
26 errorStatus.addDetail("message", "Unexpected error: " + e.message);
27 errorStatus.addDetail(
28 "remediation",
29 "Verify credentials in Administration > Operations > Services > myvendor.api",
30 );
31 return errorStatus;
32 }
33};Status codes
The Business Manager endpoint interprets the returned Status as follows:
| Return value | Health badge | When to use |
|---|---|---|
new Status(Status.OK, 'HEALTHY') | Healthy | Service is reachable and credentials are valid |
new Status(Status.ERROR, 'DEGRADED') | Degraded | Service is reachable but partially impaired (for example, auth failed, rate-limited) |
new Status(Status.ERROR, 'UNHEALTHY') | Unhealthy | Service is unreachable or returning errors |
null or exception thrown | Unknown | Hook timed out or threw — platform handles gracefully |
Status details
Use status.addDetail(key, value) to attach structured information that Business Manager surfaces in the health indicator UI:
| Detail key | Purpose | Example |
|---|---|---|
"message" | Brief description of the current state | "Service responded in 142ms" |
"remediation" | Actionable steps the merchant can take to fix a degraded or unhealthy state | "Verify your API credentials in Administration > Operations > Services > myvendor.api" |
Business Manager surfaces both values verbatim. Keep them concise and merchant-friendly.
Localizing health check messages
The hook executes in the Business Manager session locale context, so use dw.web.Resource to provide translated messages:
1var Resource = require("dw/web/Resource");
2var Status = require("dw/system/Status");
3
4exports.checkConnectionHealth = function () {
5 // ... service call logic ...
6 var degraded = new Status(Status.ERROR, "DEGRADED");
7 degraded.addDetail("message", Resource.msg("healthcheck.degraded.message", "myapp", null));
8 degraded.addDetail(
9 "remediation",
10 Resource.msgf("healthcheck.degraded.remediation", "myapp", null, serviceName),
11 );
12 return degraded;
13};Place resource bundles under cartridge/templates/resources/ in your cartridge (for example, myapp.properties for English, myapp_de.properties for German). The Resource.msg() call resolves against the Business Manager admin’s current language.
If you do not need localization, hard-coded English strings are acceptable — they flow through to the UI as-is.
Best practices
- Keep it lightweight. The platform applies a CPU timeout. Prefer a simple ping or auth-check endpoint over heavy operations.
- Always return a Status. Never return
undefined— returnnullif you cannot determine health, and the platform shows “Unknown.” - Wrap in try/catch. An unhandled exception produces an “Unknown” badge with no message. Catching the error lets you return a meaningful remediation hint.
- Be specific in remediation. Include the exact Business Manager navigation path where the merchant can fix the issue (for example,
Administration > Operations > Services > myvendor.api). - Test degraded states. Use invalid credentials or an unreachable endpoint in your sandbox to verify that degraded and unhealthy responses render correctly.
Apps without a health check
If an app does not register sfcc.app.<domain>.checkConnectionHealth, the Cart & Checkout Hub tile does not display a health badge. The health check is optional — apps that don’t depend on an external connection typically skip it.
UI-Only and Fullstack Apps
If you want full control over the UX, you replace the platform component with your own React extension. For UI-only apps, the platform provides the backend. For fullstack apps, you also build your own backend.
Building your Storefront Next extension
Your extension lives in the storefront-next/src/extensions/{app-name}/ directory of your CAP. A typical extension includes:
- One or more React components
- A
target-config.jsonthat maps your components to UI target IDs and declares context providers - Localization files under
locales/(minimum:en-US,en-GB,it-IT) - Optionally, route files under
routes/for custom pages - Optionally, a context provider component
Storefront Next tech stack:
| Layer | Technology |
|---|---|
| Framework | React 19 |
| Language | TypeScript (strict) |
| Build | Vite |
| Styling | Tailwind CSS 4 (@theme inline, no config file) |
| Components | ShadCN UI (Radix UI primitives) |
| Variants | CVA (class-variance-authority) |
| Routing | React Router 7 |
| i18n | react-i18next |
| Unit testing | Vitest + React Testing Library |
| E2E testing | CodeceptJS + Playwright |
Example extension directory structure:
1src/extensions/my-app/
2 target-config.json
3 components/
4 my-widget.tsx
5 my-other-widget.tsx
6 locales/
7 en-US/translations.json
8 en-GB/translations.json
9 it-IT/translations.json
10 providers/
11 my-app-provider.tsx # optional context provider
12 routes/
13 _app.my-app-page.tsx # optional custom route
14 hooks/
15 use-my-app.ts # optional custom hookstarget-config.json
The target-config.json defines which platform UI targets your components replace or extend, and optionally declares context providers that wrap the application root.
Components map to UITarget placeholders defined throughout the Storefront Next application. Each UITarget has a unique targetId — your extension specifies which target IDs to fill, the component file path (relative to src/), and an order value for controlling render order when multiple extensions target the same ID.
Target IDs follow the naming convention sfcc.{location}.{domain}.{capability} — for example, sfcc.orderSummary.tax.line targets the tax line item within the order summary, and sfcc.pdp.reviews.rating targets the star rating display on the product detail page.
1{
2 "components": [
3 {
4 "targetId": "sfcc.orderSummary.tax.line",
5 "path": "extensions/avatax-tax-breakdown/components/tax-line.tsx",
6 "order": 0
7 }
8 ]
9}Multiple components can target different insertion points in the same target-config.json. For example, a Ratings & Reviews extension might target the star rating, the full reviews list, and the Q&A section on the PDP:
1{
2 "components": [
3 {
4 "targetId": "sfcc.pdp.reviews.rating",
5 "path": "extensions/yotpo-reviews/components/star-rating.tsx",
6 "order": 0
7 },
8 {
9 "targetId": "sfcc.pdp.reviews.list",
10 "path": "extensions/yotpo-reviews/components/reviews-list.tsx",
11 "order": 0
12 },
13 {
14 "targetId": "sfcc.pdp.reviews.qna",
15 "path": "extensions/yotpo-reviews/components/qna-section.tsx",
16 "order": 0
17 }
18 ]
19}When multiple components target the same targetId, they are rendered in ascending order.
Context providers let your extension inject a React context provider at the application root (wrapping root.tsx). This is useful when your extension needs shared state accessible across multiple components or routes:
1{
2 "components": [
3 {
4 "targetId": "sfcc.checkout.payment.paymentMethods",
5 "path": "extensions/my-payments/components/payment-methods.tsx",
6 "order": 0
7 }
8 ],
9 "contextProviders": [
10 {
11 "path": "extensions/my-payments/providers/payments-provider.tsx",
12 "order": 0
13 }
14 ]
15}Available UI target IDs are defined throughout the Storefront Next application using the <UITarget targetId="..." /> component. Each target ID corresponds to a specific domain and location in the storefront. Consult the Storefront Next template application for the full list of available target IDs.
How UITarget replacement works at build time
UITarget is a build-time placeholder, not a runtime component. When the merchant runs pnpm dev or pnpm build, a Vite plugin scans all target-config.json files under src/extensions/, builds a component registry, and uses Babel AST transforms to:
- Replace each
<UITarget targetId="X">with the registered extension component(s) for that target ID, generating the necessaryimportstatements automatically. - Preserve children if no extension targets that ID —
<UITarget targetId="X">{defaultContent}</UITarget>rendersdefaultContent. - Remove
<UITarget>entirely if no extension targets it and no children are provided.
Context providers declared in contextProviders are similarly injected at build time by replacing the <UITargetProviders> placeholder in root.tsx with nested provider wrappers.
Important: Changes to
target-config.jsonduring development automatically restart the Vite dev server so the registry is rebuilt.
Extension routes
Extensions can add custom pages by placing route files in a routes/ subdirectory. These follow the same file-based routing conventions as the main application (React Router v7 flat routes). Route files are automatically discovered and merged into the application’s route tree at build time — no manual route registration is needed.
1src/extensions/my-app/
2 routes/
3 _app.my-app-settings.tsx # page route under the _app layout
4 action.my-app-action.ts # resource route (action endpoint)
5 resource.my-app-data.ts # resource route (data endpoint)Extension localization
Extensions must include translation files for at least en-US, en-GB, and it-IT locales. Place them at locales/{locale}/translations.json within your extension directory.
Translation namespaces are automatically generated from the extension folder name using PascalCase with an ext prefix. For example, an extension in src/extensions/my-app/ gets the namespace extMyApp.
1import { useTranslation } from "react-i18next";
2
3export function MyAppWidget() {
4 const { t } = useTranslation("extMyApp");
5 return <h1>{t("widget.title")}</h1>;
6}An aggregation script (aggregate-extension-locales) runs during the build process to discover extension locale files and generate import manifests. This is handled automatically — you don’t need to manually register your translations.
Extension installation
During installation, the extension code from your CAP is copied into the merchant’s Storefront Next project under src/extensions/{app-name}/.
On the next build, the Vite plugin reads the new target-config.json, rebuilds the component registry, and replaces UITarget placeholders with your components.
Injecting External SDK Scripts
Many Commerce Apps need to load an external JavaScript library from a vendor’s CDN — fraud beacons, analytics tags, payment SDKs, review widgets, live chat, and more. This section explains how to inject those scripts using the existing Storefront Next extension mechanisms (contextProviders and components). No framework changes are required.
Important — CDN delivery is required by most vendors. Many payment and fraud vendors’ terms of service require loading from their CDN. Fraud detection vendors use CDN delivery as part of integrity verification. Personalization tools need CDN delivery for real-time experiment configurations. Bundling these scripts would add hundreds of KB to the storefront and freeze vendors at specific versions. Always load external SDKs via
<script src="...">rather than bundling them.
How script injection works in Storefront Next
Storefront Next is a Single Page Application (SPA) built on React 19. Two properties of this architecture are critical for script injection:
-
React 19 script hoisting: Any
<script src="...">tag rendered anywhere in the React component tree is automatically hoisted to<head>and deduplicated bysrc. This means acontextProvideror component that renders a<script>tag gets that script in<head>regardless of where the component appears in the tree. Theasyncanddeferattributes pass through correctly. -
SPA navigation: Unlike traditional multi-page sites, Storefront Next navigates between pages by swapping content in place — no full page reload occurs. SDK scripts that rely on page loads to re-initialize (review widgets, fraud beacons, analytics) must be explicitly notified of route changes. Use the
useLocation()hook fromreact-routerto detect SPA navigation and re-trigger SDK logic.
Note: Inline
<script>blocks (containing code rather than asrcURL) are not hoisted by React 19. UseuseEffectfor anywindowobject initialization logic instead.
Choosing the right mechanism
| Scenario | Mechanism |
|---|---|
| SDK must load on every page (fraud, analytics, tag manager, chat) | contextProviders — wraps the entire app, loads once |
| SDK only needed on specific pages (payment SDK at checkout) | components at a scoped UI target (for example, sfcc.checkout.page.before) |
| SDK needed on multiple page types (BNPL messaging on PDP + payment form at checkout) | contextProviders (loads SDK globally) + components at each target (renders UI) |
| Widget requires DOM container + SPA re-initialization | contextProviders (loads SDK) + components (renders container, calls re-init on route change) |
Example: Global SDK via contextProvider
SDKs that must load on every page — fraud beacons, analytics, tag managers, live chat — use a contextProvider that renders a <script> tag and wraps the entire application. The provider reads merchant configuration from useConfig() and uses useLocation() to detect SPA navigation.
target-config.json:
1{
2 "components": [],
3 "contextProviders": [
4 {
5 "path": "extensions/my-fraud-app/providers/FraudBeaconProvider.tsx",
6 "order": 0
7 }
8 ]
9}FraudBeaconProvider.tsx: