Shopify Embedded App Development

Frontend Engineering advanced Building with AI ~25 min read

Overview

A Shopify embedded app runs inside the Shopify admin as an iframe — the merchant never leaves the Shopify interface. From the merchant’s perspective, the app is a native part of their admin panel. From the developer’s perspective, it’s a standalone web application (typically React or Remix) that communicates with the Shopify admin shell via a postMessage-based bridge called App Bridge. The app authenticates through Shopify’s OAuth flow, receives a session token that proves the merchant’s identity, and uses that token to make authenticated calls to both Shopify’s APIs and its own backend.

The platform includes a Shopify embedded app that provides the merchant-facing dashboard for configuring the search product: managing search indexes, reviewing AI-generated answers, adjusting ranking weights, and monitoring search analytics. This app uses Remix (the Shopify-recommended framework) with the @shopify/shopify-app-remix adapter, Polaris for UI components, and App Bridge for communication with the Shopify admin shell. The OAuth flow is handled by the adapter’s built-in middleware, and session tokens are verified on every request to the app’s backend.

Building an embedded app requires navigating Shopify’s security model: the app runs in a sandboxed iframe, cannot access the parent frame’s DOM, must handle session token rotation, must respect Content Security Policy headers injected by Shopify, and must implement mandatory webhooks for GDPR compliance (data deletion requests, shop uninstall events). The App Bridge SDK abstracts the iframe communication, providing a JavaScript API for navigation, modal dialogs, toast notifications, and resource picker widgets that render natively in the Shopify admin.

Core Concepts

App Bridge

App Bridge is Shopify’s client-side SDK for embedded apps. It provides a postMessage-based communication channel between the app’s iframe and the Shopify admin host. Through App Bridge, the app can navigate to Shopify admin pages, display toast notifications, open modal dialogs, trigger the resource picker (for selecting products, collections, or customers), and manage the top-level browser URL — all rendered by the Shopify admin shell, not by the app’s own DOM. This means the app’s UI integrates seamlessly with the admin’s navigation and notification system.

Session Tokens

Instead of traditional cookies, Shopify embedded apps use JWT-based session tokens for authentication. The App Bridge SDK provides getSessionToken(), which returns a signed JWT containing the shop domain, user ID, and expiration time. The app’s backend verifies this token on every request using the app’s secret key. Session tokens are short-lived (typically 1 minute) and refreshed automatically by App Bridge. This token-based approach avoids third-party cookie issues — since the app runs in an iframe, browsers may block cookies set by the app’s domain.

Polaris Design System

Polaris is Shopify’s open-source React component library. Embedded apps are required to use Polaris (or match its design language closely) to maintain visual consistency with the Shopify admin. Polaris provides layout components (Page, Layout, Card), form controls (TextField, Select, Checkbox), data display (DataTable, IndexTable), navigation (Tabs, Pagination), and feedback components (Banner, Toast, Modal). Using Polaris ensures the app feels native and passes Shopify’s app review.

OAuth 2.0 Installation Flow

When a merchant installs the app, Shopify redirects them to the app’s auth endpoint with a temporary authorization code. The app exchanges this code for an access token scoped to the permissions the merchant approved (read products, write orders, etc.). The access token is stored in the app’s database, associated with the shop domain. On subsequent visits, the app checks for a valid session — if none exists, it re-initiates the OAuth flow. The @shopify/shopify-app-remix adapter handles this entire flow, including token refresh for offline access tokens.

Webhook Subscriptions

Shopify sends webhooks to notify the app of events: product updates, order creation, app uninstallation, and GDPR-mandated data requests. Webhooks are HTTP POST requests signed with the app’s secret (HMAC-SHA256). The app must verify the signature before processing. Mandatory webhooks include customers/data_request, customers/redact, and shop/redact — apps that don’t implement these fail Shopify’s app review. The Remix adapter provides middleware that registers webhook handlers and verifies signatures automatically.

How It Works

App Configuration and Entry Point

The Shopify Remix adapter initializes the app with configuration for API version, authentication, session storage, and webhook registration. This setup runs once when the server starts.

// app/shopify.server.ts
import "@shopify/shopify-app-remix/adapters/node";
import { shopifyApp, LATEST_API_VERSION } from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { prisma } from "./db.server";

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY!,
  apiSecretKey: process.env.SHOPIFY_API_SECRET!,
  appUrl: process.env.SHOPIFY_APP_URL!,
  apiVersion: LATEST_API_VERSION,
  scopes: process.env.SCOPES!.split(","),
  sessionStorage: new PrismaSessionStorage(prisma),
  isEmbeddedApp: true,
  webhooks: {
    APP_UNINSTALLED: {
      deliveryMethod: "http",
      callbackUrl: "/webhooks",
    },
    PRODUCTS_UPDATE: {
      deliveryMethod: "http",
      callbackUrl: "/webhooks",
    },
  },
  hooks: {
    afterAuth: async ({ session, admin }) => {
      // Register webhooks after successful OAuth
      shopify.registerWebhooks({ session });

      // Initialize the merchant's search account
      await initializeSearchIndex(session.shop, admin);
    },
  },
});

export default shopify;
export const apiVersion = LATEST_API_VERSION;
export const { authenticate, login } = shopify;

Authenticated Route with Admin API Access

Each route in the embedded app calls authenticate.admin(request) to verify the session token and get an authenticated API client. This handles token refresh, re-authentication redirects, and CORS headers automatically.

// app/routes/app.search-config.tsx
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSubmit } from "@remix-run/react";
import {
  Page, Layout, Card, FormLayout, TextField,
  RangeSlider, Button, Banner, Toast, Frame,
} from "@shopify/polaris";
import { authenticate } from "../shopify.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { admin, session } = await authenticate.admin(request);

  // Fetch current search configuration from our backend
  const config = await prisma.searchConfig.findUnique({
    where: { shop: session.shop },
  });

  // Fetch product count from Shopify Admin API (GraphQL)
  const response = await admin.graphql(`
    query {
      productsCount {
        count
      }
    }
  `);
  const { data } = await response.json();

  return json({
    config: config ?? { boostFactor: 1.0, maxResults: 10, aiEnabled: true },
    productCount: data.productsCount.count,
  });
}

export async function action({ request }: ActionFunctionArgs) {
  const { session } = await authenticate.admin(request);
  const formData = await request.formData();

  const boostFactor = parseFloat(formData.get("boostFactor") as string);
  const maxResults = parseInt(formData.get("maxResults") as string, 10);

  await prisma.searchConfig.upsert({
    where: { shop: session.shop },
    update: { boostFactor, maxResults },
    create: { shop: session.shop, boostFactor, maxResults, aiEnabled: true },
  });

  // Notify the search indexer to re-rank with new boost settings
  await fetch(`${process.env.SEARCH_API}/reindex`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.SEARCH_INTERNAL_KEY}`,
    },
    body: JSON.stringify({ shop: session.shop, boostFactor }),
  });

  return json({ success: true });
}

export default function SearchConfigPage() {
  const { config, productCount } = useLoaderData<typeof loader>();
  const submit = useSubmit();

  const handleSave = (formData: FormData) => {
    submit(formData, { method: "post" });
  };

  return (
    <Page title="Search Configuration">
      <Layout>
        <Layout.Section>
          <Card>
            <Banner tone="info">
              Indexing {productCount.toLocaleString()} products from your store.
            </Banner>
            <FormLayout>
              <RangeSlider
                label="AI Boost Factor"
                value={config.boostFactor}
                min={0}
                max={2}
                step={0.1}
                output
              />
              <TextField
                label="Maximum Results"
                type="number"
                value={String(config.maxResults)}
                autoComplete="off"
              />
              <Button variant="primary" onClick={() => handleSave(/* ... */)}>
                Save Configuration
              </Button>
            </FormLayout>
          </Card>
        </Layout.Section>
      </Layout>
    </Page>
  );
}

Webhook Handler with HMAC Verification

Webhook endpoints verify the HMAC signature and dispatch to topic-specific handlers. The adapter handles signature verification, but the handler must process the payload and respond within Shopify’s 5-second timeout.

// app/routes/webhooks.tsx
import { type ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { prisma } from "../db.server";

export async function action({ request }: ActionFunctionArgs) {
  const { topic, shop, payload } = await authenticate.webhook(request);

  switch (topic) {
    case "APP_UNINSTALLED":
      // Clean up merchant data on uninstall
      await prisma.searchConfig.delete({ where: { shop } });
      await prisma.session.deleteMany({ where: { shop } });

      // Notify the search service to remove this shop's index
      await fetch(`${process.env.SEARCH_API}/shops/${encodeURIComponent(shop)}`, {
        method: "DELETE",
        headers: { Authorization: `Bearer ${process.env.SEARCH_INTERNAL_KEY}` },
      });
      break;

    case "PRODUCTS_UPDATE":
      // Queue a re-index for the updated product
      await fetch(`${process.env.SEARCH_API}/index/product`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.SEARCH_INTERNAL_KEY}`,
        },
        body: JSON.stringify({
          shop,
          productId: payload.id,
          title: payload.title,
          body_html: payload.body_html,
          variants: payload.variants,
          updated_at: payload.updated_at,
        }),
      });
      break;

    case "CUSTOMERS_DATA_REQUEST":
    case "CUSTOMERS_REDACT":
    case "SHOP_REDACT":
      // GDPR compliance handlers — log and acknowledge
      console.log(`GDPR webhook: ${topic} for ${shop}`);
      break;

    default:
      console.warn(`Unhandled webhook topic: ${topic}`);
  }

  return new Response(null, { status: 200 });
}

Practical Applications

The platform’s Shopify embedded app is the merchant-facing interface for the search product. When a Shopify merchant installs the app from the Shopify App Store, the OAuth flow provisions their store: creating a search index, syncing their product catalog via the Shopify Admin API, and configuring default search settings. The embedded app renders inside the Shopify admin, giving merchants a familiar environment to manage their AI search configuration.

The app uses Shopify’s Resource Picker (via App Bridge) to let merchants select which collections should be prioritized in search results. The Resource Picker renders as a native Shopify modal — the merchant sees the same collection browser they use elsewhere in their admin. The selected collection IDs are sent to the search backend to adjust ranking weights. This pattern avoids rebuilding Shopify’s complex resource browsing UI from scratch.

Analytics pages in the embedded app display search query trends, click-through rates, and AI answer quality metrics. These pages fetch data from the search analytics API and render it using Polaris’s DataTable and IndexTable components. The data refreshes via TanStack Query (see the TanStack Query training module) with automatic background refetching, so merchants see live search activity without manually refreshing the page.

Implementation Patterns

App Bridge Navigation Sync

When the user navigates within the embedded app (client-side routing via Remix), the browser URL in the Shopify admin’s address bar must update to match. App Bridge’s NavigationMenu component synchronizes the app’s routes with the admin’s breadcrumb and back button. Without this, the merchant’s browser URL stays stuck on the initial app load URL, and the admin’s back button doesn’t work correctly.

Authenticated Fetch Wrapper

Every request from the app’s frontend to its backend must include the session token. The Remix adapter provides an authenticatedFetch function that automatically retrieves a fresh session token from App Bridge and attaches it as a Bearer token on each request. This eliminates the need to manually call getSessionToken() before every fetch and handles token expiration transparently.

Offline vs. Online Access Tokens

Shopify provides two types of access tokens. Online tokens are scoped to a specific user session and expire when the user logs out — suitable for admin API calls made on behalf of the merchant. Offline tokens persist indefinitely (until the app is uninstalled) and are scoped to the shop — suitable for background jobs like webhook processing and cron-based sync tasks. The embedded app uses offline tokens for the product sync background job and online tokens for the interactive admin UI.

Common Pitfalls

Further Reading