Configure Commerce App Admin UI
Adds or modifies the adminUi block in an existing app.commerce.config.ts.
The Admin UI extension point (commerce/backend-ui/2) lets a Commerce app extend the Commerce Admin with custom grid columns, mass actions, order view buttons, a menu entry, and custom ACL resources.
Other extensibility domains (webhooks, events, business config) are added separately via their own skills.
Prerequisites
- Verify the app is scaffolded and initialized, not merely that the config exists. Require both:
app.commerce.config.tspresent in the project root, and- the project initialized — signalled by the generated
src/commerce-extensibility-1/directory and installednode_modules(the@adobe/aio-commerce-lib-appdependency).
- If
app.commerce.config.tsis missing, stop and invokecommerce-app-initfirst (it writes the config, then runs init). - If the config is present but the project is not initialized (no
src/commerce-extensibility-1/ornode_modules), runnpx @adobe/aio-commerce-lib-app initbefore continuing. Init is idempotent — it finds the existing config, skips the interactive prompts, installs dependencies, and generates the project files. - Actions can be authored in TypeScript only once the project has the TypeScript build setup (
webpack-config.cjs+ roottsconfig.json) thatinitscaffolds for a TypeScript Commerce config — seecommerce-app-init. Otherwise, author actions in JavaScript.
Extension points at a glance
| Extension point | Entities | Variants | Server handler | Reference |
|---|---|---|---|---|
| Grid columns | order, product, customer | worker only | yes | grid-columns |
| Mass actions | order, product, customer | view / worker | worker only | mass-actions |
| Order view buttons | order only | view / worker | worker only | order-view-buttons |
| Menu | single entry (adminUi.menu) |
view (iframe) | no | menu |
| Custom ACL | list (adminUi.acl) |
none | no | custom-acl |
view renders an iframe into the app's web UI (web-src) at the entry's path; worker invokes a runtime action server-side.
Custom ACL resources have no variant and no handler: they are standalone permissions Commerce renders in the User Roles tree, and the app checks them itself (see custom-acl).
Grid columns are always worker; the menu is always an iframe.
Step 1 — Understand intent
For each thing the user wants to add, gather:
- Which extension point — grid columns, mass actions, order view buttons, menu, or custom ACL resources
- Which entity —
order,product, orcustomer(grid columns and mass actions; view buttons are order-only; menu and custom ACL resources have no entity) - For mass actions and view buttons, the variant —
worker(runtime action) orview(iframe intoweb-src) - The fields for that extension point (column definitions, button labels, menu parent, etc.) — see the reference file in the table above for the full field set
Step 2 — Declare in app.commerce.config.ts
Add (or merge into) the top-level adminUi block, preserving all other domains. If adminUi already exists, merge into it rather than replacing — keep existing entities, the menu, and existing array entries.
These fields are shared across the extension points:
| Field | Constraint |
|---|---|
runtimeAction |
<package>/<action>; must resolve to a handler action you declare (Step 4) |
confirm |
Optional { title?, message? } — confirmation dialog before the action runs |
notifications |
Optional { success?, error? } — toast text Commerce shows on completion |
sandboxPermissions |
Optional array (view/iframe entries); non-empty, no duplicates; each one of allow-downloads, allow-modals, allow-popups |
Minimal example covering each feature (use only the parts you need):
import { MENU_SALES } from "@adobe/aio-commerce-sdk/admin-ui/menu";
// inside defineConfig({ ... }):
adminUi: {
order: {
// Custom column on the order grid (worker only)
gridColumns: {
label: "Fulfillment data",
description: "Fulfillment status from the warehouse system.",
runtimeAction: "my-app/order-grid", // <package>/<action> — declare in Step 4
columns: [
{ id: "fulfillment_status", label: "Fulfillment", type: "string", align: "left" },
],
},
// Bulk action on selected orders (worker variant shown)
massActions: [
{ type: "worker", id: "archive-orders", label: "Archive",
runtimeAction: "my-app/archive-orders", selectionLimit: 500 },
],
// Button on the order view page (worker variant shown)
viewButtons: [
{ type: "worker", id: "sync-inventory", label: "Sync inventory",
runtimeAction: "my-app/sync-inventory" },
],
},
// Custom Admin menu entry (iframe into the app)
menu: {
id: "my_app_dashboard", // letters, digits, / : _ only
label: "My Dashboard",
description: "Custom dashboard for my app.",
parentMenu: MENU_SALES,
},
// Custom ACL resources — standalone permissions the app checks itself (no handler)
acl: [
{ id: "reports", label: "Reports", children: [
{ id: "export", label: "Export" },
]},
{ id: "approve_refunds", label: "Approve Refunds" },
],
}
For the view variants (iframe) and the complete field set and constraints of each extension point, read the matching reference file under References before writing.
Step 3 — Register the extension point
Run init so that commerce/backend-ui/2 is added to app.config.yaml and install.yaml, and the src/commerce-backend-ui-2/ extension folder is generated. This is idempotent — safe to run even if the extension is already registered.
npx @adobe/aio-commerce-lib-app init
The build derives the extension's ext.config.yaml from your adminUi config: each worker runtimeAction becomes a workerProcess operation, and when you declare a menu or any view-type entry, a view operation plus an explicit web: web-src key are written. Those hooks, operations, and web sections are managed by the library — do not hand-edit them.
When a view operation is present, init/generate also scaffolds the web frontend automatically (skipped if web-src/index.html already exists). It generates src/commerce-backend-ui-2/web-src/ — index.html, src/app.jsx, src/pages/main-page.jsx, src/components/welcome.jsx (.tsx plus an independent tsconfig.json when the app config is TypeScript) — adds the #web/* import alias to package.json, and declares and installs pinned versions of react, react-dom, @react-spectrum/s2, and @adobe/aio-commerce-lib-admin-ui (React and Spectrum S2 are optional peer dependencies of the admin-ui library), plus some devDependencies for proper TypeScript support/config. TypeScript scaffolds also add typecheck:web-src to the project’s composed typecheck script. Do not hand-pick different versions of these dependencies; the scaffold fails if incompatible versions are already installed.
If the scaffold is skipped because web-src already exists, check its package.json for classic React Spectrum (@adobe/react-spectrum or @react-spectrum/<component> without s2) instead of @react-spectrum/s2. The two are compatible, but S2 is the version Adobe recommends moving to and the one this scaffold targets — suggest upgrading. This skill only configures the Admin UI extension, it doesn't drive the upgrade itself, so point the user to the commerce-app-migrate skill for the actual migration:
npx skills add adobe/skills --skill commerce-app-migrate
Do not install it or perform the upgrade yourself unless the user asks.
Step 4 — Implement the handlers
What you implement depends on the variant. Examples below are in TypeScript; if the project uses JavaScript, omit type imports and annotations.
Worker variants (grid columns, worker mass actions, worker view buttons)
Each worker runtimeAction needs an action you declare and implement, inside the Admin UI extension folder src/commerce-backend-ui-2/.
- Put the handler source under
src/commerce-backend-ui-2/actions/. - Declare it in
src/commerce-backend-ui-2/ext.config.yamlunder your own package inruntimeManifest(any package name). The build manageshooksandoperationsin this file but preserves the packages you add underruntimeManifest, so your action survives rebuilds:
# src/commerce-backend-ui-2/ext.config.yaml
runtimeManifest:
packages:
my-app: # must match the <package> in runtimeAction
actions:
order-grid:
function: actions/order-grid/index.js # relative to src/commerce-backend-ui-2/
web: "yes"
runtime: nodejs:24
annotations:
require-adobe-auth: true # Commerce calls the action with an IMS token — validate it
final: true
The <package>/<action> in runtimeAction maps directly: my-app/order-grid → package my-app, action order-grid. Commerce invokes these worker actions with an Adobe IMS token, so they need require-adobe-auth: true (and final: true to lock the bound inputs).
Implement the handler with the wire-contract builders from the @adobe/aio-commerce-sdk/admin-ui/* entrypoints.
The builders differ per extension point — the reference file gives the exact request shape and response builders. Grid columns example:
// src/commerce-backend-ui-2/actions/order-grid/index.ts
import {
parseGridRequest,
okGridResponse,
errorGridResponse,
} from "@adobe/aio-commerce-sdk/admin-ui/grid-columns";
import type { RuntimeActionParams } from "@adobe/aio-commerce-sdk/core/params";
export async function main(params: RuntimeActionParams) {
const { gridType, ids } = parseGridRequest(params);
try {
const rows = await fetchRows(gridType, ids);
// row keys must match the column ids declared in config
return okGridResponse(rows, { fulfillment_status: "unknown" });
} catch (error) {
return errorGridResponse(
500,
error instanceof Error ? error.message : String(error),
);
}
}
View variants (view mass actions, view buttons, menu)
No server handler. Commerce opens an iframe into the app's web-src at the entry's path — and that frontend was generated for you in Step 3 (index.html, src/app.jsx, src/pages/main-page.jsx, src/components/welcome.jsx). The generated src/app.jsx mounts the commerce/backend-ui/2 iframe app with createExtensionApp from @adobe/aio-commerce-lib-admin-ui/web, and its routes array must start with the index route:
// src/commerce-backend-ui-2/web-src/src/app.jsx (generated)
import { createExtensionApp } from "@adobe/aio-commerce-lib-admin-ui/web";
import "@react-spectrum/s2/page.css";
import config from "#app.commerce.config";
import { MainPage } from "#web/pages/main-page.jsx";
createExtensionApp({
metadata: { extensionId: config.metadata.id },
routes: [{ index: true, element: <MainPage /> }],
});
Just as the worker variants wire a runtimeAction to an action, each view entry's path must be wired to a page and a route. Scaffold that wiring per entry — but only create what is missing. Never overwrite or modify an existing page or route.
The menu has no path: it renders at the index route (src/pages/main-page.jsx), which the scaffold already created as a plain page. There is nothing to scaffold for the menu — leave main-page.jsx in place (customize its content if you like). It needs only the MENU_* constant for parentMenu in the config (see menu).
For each view-type mass action and order view button (both carry a path):
Check for the route. Look in
src/app.jsxfor aroutesentry whosepathequals the entry's configpath, and inweb-src/src/pages/for its page file. If a route for thatpathalready exists, leave it and its page untouched and move on.Create the placeholder page (missing only). Add
web-src/src/pages/<name>.jsx— use.tsx(and TypeScript) when the app config is TypeScript. Keep it minimal, matching the look of the generatedmain-page/welcome(a<main>with a heading). Do not add a copyright header — the generatedweb-srcfiles carry none. Pre-wire the context hook for the entry type (table below).Register the route in
src/app.jsx(missing only). Import the page via the#web/pages/*alias and append a{ path, element }entry toroutes. Writepathas the exact same string as the configpath, including the leading#/— copy it verbatim so the route and the config entry visibly line up. Keep the index route first.
Pre-wire the hook by view type — all from @adobe/aio-commerce-lib-admin-ui/web:
| View entry | Context hook | Also | Reference |
|---|---|---|---|
Mass action (view) |
useMassActionContext() → { data, error }; use data.selectedIds |
useHostConnection() → { actions, error }; use actions.close() |
mass-actions |
| Order view button | useOrderViewButtonContext() → { data, error }; use data.orderId |
useHostConnection() → { actions, error }; use actions.close() |
order-view-buttons |
| Menu | none (plain index page) | — | menu |
These hooks return errors instead of throwing them. A route can throw a returned error during render to send it to the SDK's error boundary, which replaces the extension content with its fallback UI. If the route must stay mounted, handle the error locally by rendering a message, offering a retry, disabling the affected feature, or providing another degraded state. The placeholder examples below throw because they don't define custom recovery UI.
A route component may also call useIms() and useCommerce() to reach the Commerce REST API or retrieve data directly from the host, respectively. Both return { data, error }; after handling error, read data.imsToken and data.imsOrgId from useIms(), and data.commerceHost from useCommerce(). The generated Welcome component demonstrates this result handling for useIms(). Add those hooks only when the page actually needs them.
Example — a view mass action placeholder page and its route registration:
// src/commerce-backend-ui-2/web-src/src/pages/export-customers.jsx
import {
useHostConnection,
useMassActionContext,
} from "@adobe/aio-commerce-lib-admin-ui/web";
export function ExportCustomersPage() {
const { data, error: contextError } = useMassActionContext();
const { actions, error: hostError } = useHostConnection();
if (contextError) throw contextError;
if (hostError) throw hostError;
const { selectedIds } = data; // non-empty string[] — the selected record ids
const { close } = actions; // await close() (or actions.closeWithError()) when done
return (
<main>
<h1>Export customers</h1>
<p>{selectedIds.length} selected</p>
</main>
);
}
// src/commerce-backend-ui-2/web-src/src/app.jsx
import { createExtensionApp } from "@adobe/aio-commerce-lib-admin-ui/web";
import "@react-spectrum/s2/page.css";
import config from "#app.commerce.config";
import { MainPage } from "#web/pages/main-page.jsx";
import { ExportCustomersPage } from "#web/pages/export-customers.jsx";
createExtensionApp({
metadata: { extensionId: config.metadata.id },
routes: [
{ index: true, element: <MainPage /> }, // keep the index route first
{ path: "#/export-customers", element: <ExportCustomersPage /> }, // path === config `path`
],
});
For an order view button, swap the hook for useOrderViewButtonContext() and read data.orderId after handling error — see order-view-buttons.
Calling your own action from a menu/view page
A menu or view page sometimes needs to fetch its own data — a custom menu page rendering a table, for example — instead of receiving it from Commerce. For that, it calls one of the app's own actions directly from web-src.
Whenever an extension declares both actions and web-src, aio app build/aio app dev auto-generate web-src/src/config.json (config.web.injectedConfig) with every action's live URL — gitignored, regenerated on every build, nothing to scaffold by hand:
// src/commerce-backend-ui-2/web-src/src/hooks/use-config.js
import { useCallback } from "react";
import actionUrls from "../config.json";
export function useConfig() {
return { getActionUrl: useCallback((action) => actionUrls[action], []) };
}
// Usage — combine with useIms() for the auth headers:
import { useIms } from "@adobe/aio-commerce-lib-admin-ui/web";
const { data, error } = useIms();
if (error) throw error;
const response = await fetch(getActionUrl("my-app/order-grid"), {
headers: {
authorization: `Bearer ${data.imsToken}`,
"x-gw-ims-org-id": data.imsOrgId, // required for require-adobe-auth: true actions, or the request fails
},
});
Step 5 — Validate
Build the project to confirm the updated config is valid:
aio app build
A build failure with a validation error points directly to the offending adminUi field.
Common Issues
viewvsworkermismatch: each variant is a strict object — aworkerentry requiresruntimeAction(and rejectspath/sandboxPermissions); aviewentry requirespath(and rejectsruntimeAction/timeout). They are discriminated bytype.- Grid columns are worker-only: there is no
viewgrid column. Onlyorder,product, andcustomersupportgridColumns, and onlyordersupportsviewButtons. runtimeActionwith no handler: a worker entry whose<package>/<action>is not declared underruntimeManifestinsrc/commerce-backend-ui-2/ext.config.yamlleaves the generatedworkerProcessreference unresolved at deploy.- Wrong action location: handler sources and their
runtimeManifestentry belong in the Admin UI extension foldersrc/commerce-backend-ui-2/— notsrc/commerce-extensibility-1/(where webhook and event handlers live). Thefunctionpath is relative tosrc/commerce-backend-ui-2/. - Grid row keys must match column ids: keys in the
okGridResponserows must equal theids ingridColumns.columns, or cells render empty (or fall back to the defaults bag). - View route
path: register the{ path }insrc/app.jsxas the exact same string as the entry's configpath— copy it verbatim, hash included, so the two line up. - Menu
idcharset: the menuidallows only letters, digits,/,:, and_— no hyphens or spaces. defineConfignot found: importdefineConfigfrom@adobe/aio-commerce-lib-app/config.- Double renders/requests in development: local builds served by
aio app devandaio app runare development builds, wherecreateExtensionAppwraps the app in React<StrictMode>— so components render twice and effects run an extra setup + cleanup cycle on mount. Duplicate renders or effect-triggered requests during local development are expected StrictMode behavior, not a bug to fix.aio app buildandaio app deployproduce production builds, which render without<StrictMode>(it is stripped from the bundle).
Quality Bar
aio app buildcompletes without errors- Every worker
runtimeActionhas a matching action declared underruntimeManifestinsrc/commerce-backend-ui-2/ext.config.yaml
Chaining
After aio app build passes:
- Add merchant settings — invoke
commerce-app-business-configto expose configurable settings in Commerce Admin - Add webhook interceptors — invoke
commerce-app-webhooksto intercept Commerce operations - Add event subscriptions — invoke
commerce-app-eventingto subscribe to Commerce or external events - Add persistent storage — invoke
commerce-app-storageto back worker actions with queryable DB storage
References
- references/grid-columns.md — Grid column config and handler contract
- references/mass-actions.md — Mass action config (view and worker) and handler contract
- references/order-view-buttons.md — Order view button config (view and worker) and handler contract
- references/menu.md — Menu config and parent-menu constants
- references/custom-acl.md — Custom ACL resource config and runtime permission checks