Skip to main content

Admin Block Extensions

An admin block is a sandboxed iframe the host renders inline on a merchant admin page (product, order, customer, etc.). Use blocks for UI that is always present on the page — review summaries, fulfilment status, warehouse KPIs, or anything you want the merchant to see without clicking a button. If you want a button that opens a modal on demand, use an Admin Action instead.

How the Host Renders It

<AdminExtensionSlot target="..." resourceId resourceType domainSlug /> mounts a sandboxed iframe per registered extension:
<iframe
  src="<appUrl-with-query-params>"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
  style="width: 100%; height: 200px; border: none;"
  loading="lazy"
/>
Default height is 200px; resize requests are clamped to a maximum of 2000px (see Resize below). The host calls GET /api/apps/admin-extensions?target=<target>&domainSlug=<slug> to discover registered extensions, then renders one iframe per result.

Available Targets

Targets are filtered by exact string match against the wired slots in the LaunchMyStore admin. Every slot uses the admin.<resource>.render naming convention — your manifest’s target must match exactly. 26 admin-block slots are currently wired (plus separate slots for admin actions and print actions):
Building a backend entry for a product page — like an “Edit SEO”, “Reviews summary”, or “Inventory note” panel that staff see while viewing a product? Use target: "admin.product-details.block.render". The host appends resourceId (the product UUID) and resourceType=product to your iframe URL, so you can fetch the right product immediately without a separate routing step.

Resource Detail Pages

TargetWhere it renders
admin.product-details.block.renderProduct detail page
admin.order-details.block.renderOrder detail page
admin.customer-details.block.renderCustomer detail page
admin.collection-details.block.renderCollection detail page
admin.discount-details.block.renderDiscount detail page
admin.abandoned-order-details.renderAbandoned checkout detail

List Pages

TargetWhere it renders
admin.product-list.renderProducts list
admin.order-list.renderOrders list
admin.customer-list.renderCustomers list
admin.collection-list.renderCollections list
admin.abandoned-order-list.renderAbandoned checkouts list
admin.gift-card-list.renderGift cards list
admin.blog-list.renderBlogs list
admin.contact-list.renderContact submissions
admin.newsletter-list.renderNewsletter subscribers

Settings Pages

TargetWhere it renders
admin.settings.renderMain settings page
admin.shipping-settings.renderShipping settings
admin.payment-settings.renderPayment settings
admin.pos-settings.renderPOS settings
admin.account-settings.renderAccount settings

Analytics

TargetWhere it renders
admin.analytics.renderMain analytics dashboard
admin.sales-analytics.renderSales analytics page
admin.product-analytics.renderProduct analytics page
admin.inventory.renderInventory page

Other

TargetWhere it renders
admin.order-create.renderOrder creation page
admin.app.configurationApp configuration screen (no .render suffix)
The slot string is matched exactlyproduct.details.block won’t render at admin.product-details.block.render. Always copy the target value verbatim from the table above.
Targets not in the tables above won’t render even if the API accepts the upload — only the wired slots resolve. If you need a new target, open a support ticket with the page and resource type you want.

Manifest

Declare admin blocks under extensions.adminExtensions[] in your app’s app.json:
{
  "handle": "my-reviews-app",
  "name": "Product Reviews",
  "version": "1.0.0",
  "extensions": {
    "adminExtensions": [
      {
        "handle": "reviews-panel",
        "title": "Reviews",
        "target": "admin.product-details.block.render",
        "url": "https://my-app.example.com/admin/product-reviews"
      },
      {
        "handle": "reviews-summary",
        "title": "Reviews Summary",
        "target": "admin.analytics.render",
        "url": "https://my-app.example.com/admin/reviews-analytics"
      }
    ]
  }
}
FieldRequiredDescription
handleyesURL-safe identifier unique within the app.
targetyesOne of the wired slots above.
urlyesHTTPS URL loaded into the iframe. appUrl is an accepted alias.
titlenoDisplay label (defaults to handle).
iconUrlnoURL of an SVG/PNG icon.
permissionsnoArray of permission strings — purely informational at this time.
Relative URLs (e.g. /extensions/.../iframe.html) are absolutised by the host before being returned to the admin. You always get back an absolute, HTTPS-only URL — no domain-resolution surprises inside the iframe.

Iframe URL Parameters

The host appends the following query params when building the iframe src:
ParameterAlways setValue
targetyesThe extension’s manifest target.
domainSlugyesThe merchant’s domain slug.
extensionIdyesThe host-assigned extension id. You must echo this on APP_BRIDGE_RESIZE messages so the host filters them correctly.
hostyesbtoa(window.location.origin) — base64-encoded admin origin, used to initialise the App Bridge SDK.
resourceIdoptionalPresent when the slot was rendered with a resourceId prop (e.g. on admin.product-details.block.render).
resourceTypeoptionalPresent together with resourceId — e.g. "product", "order".
const params = new URLSearchParams(location.search);
const target       = params.get('target');         // "admin.product-details.block.render"
const domainSlug   = params.get('domainSlug');     // "acme-store"
const extensionId  = params.get('extensionId');    // host-assigned uuid
const host         = params.get('host');           // base64 origin
const resourceId   = params.get('resourceId');     // product id (UUID)
const resourceType = params.get('resourceType');   // "product"
productId, orderId, customerId, collectionId, and locale are not passed as URL params. Read resourceId + resourceType instead and resolve the resource server-side using your session token.

Bootstrap

Initialise the App Bridge SDK with the apiKey you registered the app under, and the base64 host from the URL:
import { createApp } from '@launchmystore/app-bridge';

const params = new URLSearchParams(location.search);
const app = createApp({
  apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
  host: params.get('host'),
});
app.dispatch(action, payload) and app.dispatchAndWait(action, payload) take an action string and a payload object — not a {type, payload} envelope.

Resizing the Iframe

The iframe starts at 200px. To grow it, post the resize message directly — this one is not routed through app.dispatch:
const params = new URLSearchParams(location.search);
const extensionId = params.get('extensionId');

window.parent.postMessage({
  type: 'APP_BRIDGE_RESIZE',
  extensionId,
  height: document.body.scrollHeight,
}, '*');
The host clamps the height to 2000px and applies it only if extensionId matches. Omit extensionId and the resize is ignored — your iframe stays empty-looking at 200px. A typical auto-resize hook using ResizeObserver:
const extensionId = new URLSearchParams(location.search).get('extensionId');

const observer = new ResizeObserver(() => {
  window.parent.postMessage({
    type: 'APP_BRIDGE_RESIZE',
    extensionId,
    height: document.body.scrollHeight,
  }, '*');
});
observer.observe(document.body);

App Bridge Actions (Admin host)

Admin blocks can use the full admin App Bridge action set. See App Bridge Overview for the complete reference. Common calls:

Show a toast

app.dispatch('TOAST_SHOW', {
  message: 'Review approved!',
  duration: 3000,
  type: 'success',     // 'success' | 'error' | 'warning' | 'info'
});

Open a modal

import { Modal } from '@launchmystore/app-bridge';

const modal = Modal.create(app, {
  title: 'Delete review?',
  message: 'This action cannot be undone.',
  primaryAction:   { label: 'Delete', onAction: () => doDelete() },
  secondaryActions: [{ label: 'Cancel', onAction: () => modal.close() }],
});
modal.dispatch();
app.dispatch('REDIRECT', {
  url: '/admin/products',
  newContext: false,
});

// or the action-builder alias
app.redirect.dispatch({ url: '/admin/products', newContext: false });

Resource picker

import { ResourcePicker } from '@launchmystore/app-bridge';

ResourcePicker
  .create(app, { resourceType: 'product', multiple: true })
  .subscribe('select', (payload) => {
    console.log(payload.selection);
  })
  .dispatch();

Session token (for backend calls)

const token = await app.getSessionToken();
const response = await fetch('/api/reviews', {
  headers: { Authorization: `Bearer ${token}` },
});
The SDK does expose LOADING_START / LOADING_STOP actions (dispatched by the React useLoading hook) that draw a thin progress bar on the admin host’s title bar. They’re for host-level affordance — inside your block iframe, render loading skeletons in your own UI rather than relying on the global bar.

Example: Reviews Panel

import { createApp } from '@launchmystore/app-bridge';
import { useEffect, useState } from 'react';

export default function ProductReviewsPanel() {
  const [product, setProduct] = useState(null);
  const [reviews, setReviews] = useState([]);
  const [loading, setLoading] = useState(true);

  const params = new URLSearchParams(location.search);
  const app = createApp({
    apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
    host: params.get('host'),
  });

  useEffect(() => {
    const productId = params.get('resourceId');
    if (productId) loadReviews(productId);
  }, []);

  async function loadReviews(productId) {
    const token = await app.getSessionToken();
    const res = await fetch(`/api/reviews?productId=${productId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const data = await res.json();
    setProduct(data.product);
    setReviews(data.reviews);
    setLoading(false);

    // Auto-resize once data is rendered
    requestAnimationFrame(() => {
      window.parent.postMessage({
        type: 'APP_BRIDGE_RESIZE',
        extensionId: params.get('extensionId'),
        height: document.body.scrollHeight,
      }, '*');
    });
  }

  function handleViewAll() {
    app.dispatch('REDIRECT', {
      url: `/admin/apps/my-reviews-app/products/${product.id}`,
    });
  }

  if (loading) return <div className="loading">Loading reviews...</div>;

  return (
    <div className="reviews-panel">
      <div className="panel-header">
        <h3>Customer Reviews</h3>
        <button onClick={handleViewAll}>View All</button>
      </div>

      <div className="reviews-summary">
        <div className="stat">
          <span className="stat-value">{reviews.length}</span>
          <span className="stat-label">Total Reviews</span>
        </div>
      </div>

      {reviews.slice(0, 3).map((review) => (
        <div key={review.id} className="review-item">
          <span>{review.author}</span>
          <span>{'★'.repeat(review.rating)}</span>
          <p>{review.body}</p>
        </div>
      ))}
    </div>
  );
}

Discovery API

GET /api/apps/admin-extensions?target=<target>&domainSlug=<slug> returns all installed extensions for that target. Pass &type=admin_block to exclude action and print-action entries that also live in /api/apps/admin-extensions.
{
  "extensions": [
    {
      "id": "reviews-panel",
      "appId": "my-reviews-app",
      "name": "Product Reviews",
      "handle": "reviews-panel",
      "url": "https://your-app.example.com/extensions/.../iframe.html",
      "appUrl": "https://your-app.example.com/extensions/.../iframe.html",
      "title": "Reviews",
      "iconUrl": null,
      "target": "admin.product-details.block.render",
      "type": "admin_block",
      "permissions": []
    }
  ]
}
The url and appUrl fields are server-absolutised so the admin always receives an iframe-able HTTPS URL.

Security

Admin blocks render with allow-scripts allow-same-origin allow-forms allow-popups. The bridge postMessage protocol is the only cross-frame channel.
Anything the iframe sends to your backend should be authorised with a fresh session token (app.getSessionToken()) and verified against your app’s clientSecret server-side.
url / appUrl must be HTTPS. HTTP URLs are rejected by the install validator.
resourceId and domainSlug come from the merchant’s browser. Always re-fetch the resource server-side with the session token before trusting it.

Installing

Admin blocks are declared in app.json; the install pipeline doesn’t write per-handle schema files for them (unlike admin actions and print actions). Instead, each adminExtensions[] entry is fanned out into a registry row scoped to (appId, storeId, handle, target) so the discovery API can return it. Registry fan-out runs automatically on both install paths:
  • OAuth install — when the merchant completes POST /apps/oauth/token with grant_type=authorization_code, the install pipeline creates the installation, then synchronously registers every adminExtensions[] entry from your app’s published manifest.
  • Direct/developer installPOST /apps/store/install/:appId runs the same fan-out.
Re-running install (reinstall, or the developer pushing a new app version) is idempotent: rows keyed by (appId, storeId, handle, target) are upserted, not duplicated. Uninstall destroys all registry rows for that (appId, storeId) pair. The target column accepts any string verbatim — there is no enum cap on what you can register — but as noted above the host only renders entries whose target matches one of the wired slot tables.

See Also