Shopify customer account UI extensions are Preact modules that render inside Shopify’s customer accounts at named targets. There are four kinds: block targets the merchant places, static targets fixed to one spot, order action buttons that open a modal, and full pages with their own URL. Choose by where the customer is when they need the feature, then query the Customer Account API from inside the extension with no extra auth.
The auth is the pleasant surprise. The extension runs in a Web Worker on Shopify’s side, and a fetch to the shopify://customer-account/api/... endpoint is already authenticated as the logged-in customer. You never touch an OAuth flow.
Everything below is written against API version 2026-07, the latest stable version listed in the customer account UI extensions reference as of October 2026.
Why does an app need these now?
Because the old way is going. On 26 February 2026 Shopify deprecated legacy customer accounts: they are no longer available to new stores or to existing stores that were not already using them. A final sunset date is promised for later in 2026.
If your app injected markup into customers/account.liquid or customers/order.liquid through a theme snippet, that surface is shrinking under you. The changelog is direct about it: apps that depend on legacy Liquid pages must migrate to customer account UI extensions, and theme developers should stop shipping the legacy account templates at all.
The upside is that an extension survives theme changes. A merchant can swap themes on a Tuesday and your loyalty block is still there on Wednesday, because it never lived in the theme.
Which target should the extension use?
Shopify groups the targets by who controls placement, and that is the first decision to make.
Block targets hand placement to the merchant. There are three: customer-account.order-status.block.render, customer-account.order-index.block.render and customer-account.profile.block.render. The merchant drags the extension into position in the checkout and accounts editor, and up to three extensions can share one block location. A loyalty balance, a warranty card or a “your next delivery” panel belongs here.
Static targets keep placement for Shopify. Each one renders at a fixed point tied to a page feature, such as customer-account.order-status.fulfillment-details.render-after, or customer-account.order-status.cart-line-item.render-after, which renders once per line item. If that feature is missing from the page, so is your extension.
Order actions come as a pair. customer-account.order.action.menu-item.render draws a button on both the Order index and Order status pages, and customer-account.order.action.render is the modal that opens when that button has no href. Anything the customer does to an order goes here: report a problem, request a return, change a delivery note.
Full pages get their own route. customer-account.page.render is for pages that belong to the account, like a wishlist or a rewards dashboard, and customer-account.order.page.render is for a page about one order.
So: if the customer reads it, use a block. If they act on an order, use an order action. If the task needs more room than a modal, or a URL of its own, use a full page. Reach for a static target only when the content makes no sense away from one specific section.
How do you build an order action?
Two modules in one extension, one per target. The shopify.extension.toml:
api_version = "2026-07"
[[extensions]]
type = "ui_extension"
name = "Report a problem"
handle = "report-problem"
[[extensions.targeting]]
module = "./src/MenuItem.jsx"
target = "customer-account.order.action.menu-item.render"
[[extensions.targeting]]
module = "./src/ActionModal.jsx"
target = "customer-account.order.action.render"
[extensions.capabilities]
network_access = true
The app also needs customer_read_customers and customer_read_orders in the [access_scopes] of shopify.app.toml before the Customer Account API will answer order queries.
The menu item decides whether to show
The menu item’s root must be a single s-button. Returning null hides it, which is how you keep an action off orders it does not apply to. This version only offers “Report a problem” once something has shipped:
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
export default async () => {
let hasFulfillments = false;
try {
const response = await fetch(
'shopify://customer-account/api/2026-07/graphql.json',
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
query: `query Order($orderId: ID!) {
order(id: $orderId) {
fulfillments(first: 1) {
nodes { latestShipmentStatus }
}
}
}`,
variables: {orderId: shopify.orderId},
}),
},
);
const {data} = await response.json();
hasFulfillments = data.order.fulfillments.nodes.length > 0;
} catch (error) {
console.error(error);
}
render(<MenuItem show={hasFulfillments} />, document.body);
};
function MenuItem({show}) {
if (!show) return null;
return <s-button>Report a problem</s-button>;
}
The query runs before render, so the button never flashes in and then disappears. If the request fails, the catch leaves hasFulfillments false and the button stays hidden, which beats showing an action that cannot work.
Give the button an href instead and it works as a link rather than opening the modal. Use that when the task needs a full page.
The modal does the work
The modal’s root must be s-customer-account-action. Buttons go in the primary-action and secondary-actions slots, and shopify.close() dismisses it:
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';
export default async () => {
render(<ReportProblem />, document.body);
};
function ReportProblem() {
const [reason, setReason] = useState('damaged');
const [saving, setSaving] = useState(false);
async function submit() {
setSaving(true);
try {
const token = await shopify.sessionToken.get();
await fetch('https://api.example.com/problems', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({orderId: shopify.order.value?.id, reason}),
});
shopify.close();
} catch (error) {
console.error(error);
setSaving(false);
}
}
return (
<s-customer-account-action heading="Report a problem">
<s-select
label="What went wrong?"
value={reason}
onChange={(event) => setReason(event.target.value)}
>
<s-option value="damaged">Item arrived damaged</s-option>
<s-option value="missing">Items missing</s-option>
<s-option value="wrong">Wrong item sent</s-option>
</s-select>
<s-button slot="primary-action" loading={saving} onClick={submit}>
Send report
</s-button>
<s-button slot="secondary-actions" onClick={() => shopify.close()}>
Cancel
</s-button>
</s-customer-account-action>
);
}
How does the extension reach your own backend?
There are three routes for data, and they need different permissions.
The Customer Account API needs nothing extra. The reference states that those requests are authenticated automatically and need no additional capabilities.
The Storefront API, through shopify.query(), needs api_access = true under [extensions.capabilities]. That is how Shopify’s own full-page wishlist tutorial loads product data.
Your own server needs network_access = true, plus approval for network access in the Partner Dashboard. Because the code runs in a Web Worker, your server must reply with Access-Control-Allow-Origin: * or the browser drops the response. Authenticate the request with shopify.sessionToken.get(), which returns a JWT that expires after five minutes. Fetch it just before each call rather than caching it, and check the signature, exp and aud on the server. The sub claim carries the customer’s GID when they are logged in and the app has the read_customers scope. The verification itself is the same job we covered in our session token verification walkthrough.
One more gate: if the extension touches customer data, the app needs protected customer data access approved before it can go live. Apply early, because that review is not something you want blocking a launch.
When should it be a full page instead?
When a modal is too small, or when the customer needs to bookmark the thing. A full-page extension renders between the account header and footer, uses s-page as its root, and gets a static URL the merchant can link to from a menu or an email.
Two constraints shape the design. A full-page target cannot share an extension with any other target, so the profile block that links to your page lives in a separate extension. That block links with the extension: protocol, for example href="extension:wishlist/" where wishlist is the full-page extension’s handle. And the order-specific customer-account.order.page.render does not allow direct linking at all, so customers reach it through an order action button or a block on the Order status page.
Size is the other limit. A compiled UI extension bundle cannot exceed 64 KB, or 128 KB for a full-page extension, and deploy fails past that. Every dependency counts against that ceiling, so read the build output before adding one.
What I would build first
For most apps moving off legacy account templates, start with one order action and one profile block. The order action covers the “do something about this order” cases that used to be a link on customers/order.liquid, and the block covers the “show the customer their status” cases. Build the full page only when the modal genuinely cannot hold the task, because it costs a second extension and a linking path.
I would not use a customer account extension for anything a logged-out shopper needs. These targets live behind login. If the feature belongs on a headless storefront instead, use the Customer Account API directly, as in our guide to Customer Account API authentication, and keep the extension for the account pages Shopify hosts. Checkout is a separate surface with its own targets again, covered in our checkout UI extensions breakdown.
Whoooop builds Shopify apps and extensions, including moving apps off legacy customer account templates before the sunset date lands. If yours still writes into customers/account.liquid, our Shopify development work starts with mapping each piece to a target.