Shopify Admin UI Extensions: Action or Block?

Shopify admin UI extensions put your app’s code inside the Shopify admin itself: an admin action is a menu item under “More actions” that opens a modal, and an admin block is a card pinned inline on a resource page. Both are Preact modules rendered by Shopify, both get the current resource’s ID from shopify.data.selected, and both can call the Admin GraphQL API directly without a round trip to your backend.

The decision between them is about the shape of the work. An action is a transaction: open, change something, close, page refreshes. A block is a view: always there, showing your app’s data next to Shopify’s, with inputs if you need them. Most apps that need both end up with a block that launches an action.

Everything below was written against Admin UI Extensions API version 2026-07, as of October 2026. If your extension targets 2025-07 or earlier, the component model is different (React components from @shopify/ui-extensions-react/admin rather than Polaris web components) and the samples here will not apply.

Which should I build, an admin action or an admin block?

Build an action when a merchant does something to a resource and then wants to get on with their day: apply a label, push an order to a 3PL, generate a document, split a product into bundles. The modal gives you a heading, a primary and secondary button slot, and a close() call that dismisses it and refreshes the page underneath. Nothing persists on the page once it closes, which is the point.

Build a block when the merchant needs to see something every time they open the page: stock at a warehouse Shopify does not know about, a sync status, a list of related tickets. Merchants have to add the block to the page themselves before it shows, so it is opt-in per page, and once there it stays until they remove it. The API reflects the difference, since a block has no close().

There is a third, cheaper option. If what you have is a workflow with several screens of its own, an admin link extension sends the merchant into your app with the resource ID in hand, which is how Shopify Flow gets from an order page to its automation runner. A modal is the wrong container for that much UI, and a link costs you a TOML file and a route you already have.

The targets tell you where each type can go. Actions attach to twenty-two render targets as of 2026-07, covering the details pages for products, variants, orders, draft orders, customers, collections, discounts, gift cards, companies, catalogs and abandoned checkouts, plus the matching index pages. Blocks have twelve, all on details pages (no index pages, because there is no resource to pin to). The full list lives on the extension targets reference.

Bulk work gets its own targets. admin.order-index.selection-action.render fires when the merchant ticks rows on the orders list, and shopify.data.selected then holds every ticked ID rather than one.

How do I configure the extension and read the selected resource?

Scaffold with the CLI rather than by hand, because the template tracks the current API version and the uid is generated for you:

shopify app generate extension --template admin_action --name order-flag-action

That produces a shopify.extension.toml next to the source. The important lines are the type, the target and the module path:

api_version = "2026-07"

[[extensions]]
name = "t:name"
handle = "order-flag-action"
uid = "generated-by-the-cli-do-not-edit"
type = "ui_extension"

[[extensions.targeting]]
module = "./src/ActionExtension.jsx"
target = "admin.order-details.action.render"

[extensions.targeting.should_render]
module = "./src/shouldRender.js"

One action extension declares one render target. If you want the same action on orders and on draft orders, that is two extensions in the app.

The module itself is a default export that renders into document.body. Shopify provides a shopify global with everything you need; the Action Extension API lists close, data, query, auth, i18n, intents, picker, resourcePicker and storage. The Block Extension API is the same set minus close and plus navigation.

import { render } from "preact";
import { useState } from "preact/hooks";

export default async () => {
  render(<Extension />, document.body);
};

function Extension() {
  const { close, data } = shopify;
  const orderId = data.selected[0].id;
  const [note, setNote] = useState("");
  const [saving, setSaving] = useState(false);

  async function save() {
    setSaving(true);
    const res = await fetch("shopify:admin/api/graphql.json", {
      method: "POST",
      body: JSON.stringify({
        query: `mutation Flag($ownerId: ID!, $value: String!) {
          metafieldsSet(metafields: [{
            ownerId: $ownerId,
            namespace: "$app",
            key: "flag_note",
            type: "single_line_text_field",
            value: $value
          }]) {
            userErrors { field message }
          }
        }`,
        variables: { ownerId: orderId, value: note },
      }),
    });
    const { data: result } = await res.json();
    setSaving(false);
    if (result.metafieldsSet.userErrors.length === 0) close();
  }

  return (
    <s-admin-action heading="Flag this order">
      <s-text-field
        label="Reason"
        value={note}
        onInput={(e) => setNote(e.currentTarget.value)}
      />
      <s-button slot="primary-action" onClick={save} disabled={saving}>
        Save
      </s-button>
      <s-button slot="secondary-actions" onClick={close}>
        Cancel
      </s-button>
    </s-admin-action>
  );
}

If ESLint complains about shopify being undefined, add it to globals as readonly. The components are Polaris web components (s-admin-action, s-button, s-text-field and the rest) delivered through Remote DOM: you write the tree, Shopify renders it in the admin’s own frame, which is why there is no stylesheet to import and no way to inject arbitrary HTML.

Can an admin UI extension call the Admin API without a backend?

Yes, and that is the part that changes how you architect the app. Any fetch() from the extension to shopify:admin/api/graphql.json is authenticated by Shopify before it leaves the admin. The admin extensions overview calls this Direct API access, and shopify.query() is a thin wrapper over the same thing. No session token to mint, no proxy route in your Remix or React Router app, no latency from a hop through your server.

Two constraints. The request runs under the access scopes declared in your shopify.app.toml, so the metafield write above needs write_orders there or it fails with an access error at runtime, not at deploy. And it runs in online access mode by default, meaning it acts as the staff member who is logged in; set direct_api_mode to offline in the app TOML if the work should be attributed to the app installation instead. Direct API access also cannot manage storefront access tokens.

When you still need your own server (a webhook you own, a third-party API with secret credentials), call shopify.auth.idToken() and send the JWT as a bearer. Verifying it is the same flow as for an embedded app page; our post on session token verification and token exchange covers the server side.

How do I hide an action when it does not apply?

The should_render module in the TOML above is a second entry point that runs before the menu is drawn. It returns { display: boolean } and has the same shopify global, so it can query the resource first. Shopify’s conditional action tutorial hides a product action unless the product has more than one variant:

import { getVariantsCount } from "../utils";

export default async function extension() {
  const { data } = shopify;
  const variantCount = await getVariantsCount(data.selected[0].id);
  return { display: variantCount > 1 };
}

Keep that query small. Shopify runs the module each time it decides whether your menu item should appear, so whatever it awaits sits between the merchant and your action. A single count on the selected resource is fine; walking a connection is not.

How does a block open an action?

A block cannot modify the page in a transaction the way an action can, so the common pattern is a block that shows state and a button that hands off to the action for edits. The block’s navigation.navigate() takes an extension: URL built from the action’s handle in its TOML, and you can append a query string the action reads back:

<s-button
  onClick={() => shopify.navigation.navigate("extension:order-flag-action?reason=late")}
>
  Flag order
</s-button>

The restriction, from the connect extensions tutorial, is that navigation only works between extensions on the same resource page. A block on admin.product-details.block.render can open an action on admin.product-details.action.render; it cannot open one on the order page.

Because Shopify refreshes the page when an action closes, the block re-renders with the new data without you wiring anything. That refresh is the contract that makes the split clean: the action owns writes, the block owns reads.

What I would do

Start with an action, even if the brief says “card”. An action ships without the merchant doing anything beyond installing the app, it tests cleanly with shopify app dev from a details page, and it forces the question of what the one transaction actually is. Add a block only when someone needs to see your data on every visit and is willing to pin it. Reach for an admin link when the workflow needs more than one screen, and stop trying to fit it in a modal.

I would not use these for anything the storefront needs. Admin extensions never render to shoppers; that is the job of a theme app extension or a Function. And I would not pick 2025-07 or older for a new build, because the React component layer stopped there and every version since is Preact and web components.

Whoooop builds embedded Shopify apps and the admin extensions that go with them, from the first action to the block and link set that follows. If you are deciding where a feature should live in the admin, our Shopify development work covers that call and the build after it.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch