Skip to main content

App Architecture

Building a Shopify app requires understanding how your application integrates with the Shopify platform at every layer -- from authentication and session management to UI rendering and event handling. This lesson covers the foundational architecture decisions and patterns you need to build production-quality Shopify apps.

App Types

Shopify supports three main app types, each suited to different use cases:

Public Apps

Public apps are listed on the Shopify App Store and can be installed by any merchant. They go through an app review process and must meet Shopify's quality, security, and performance requirements.

  • Distributed via the Shopify App Store
  • OAuth-based authentication with each merchant
  • Must handle multi-tenant data isolation
  • Revenue shared with Shopify (Shopify takes 0% on the first $1M, then a percentage)

Custom Apps

Custom apps are built for a single merchant or organization. They are not listed on the App Store and are installed directly through the Shopify Admin.

  • Built for a specific store or Plus organization
  • Use custom app access tokens (no OAuth flow needed)
  • Simpler architecture since they serve one tenant
  • No App Store review required

Draft Apps

Draft apps are apps in development that have not been submitted for review. They can be installed on development stores for testing.

  • Created through the Partners Dashboard or Shopify CLI
  • Installed on dev stores via the install URL
  • Full functionality for testing and development
  • Converted to public or custom apps when ready
Choosing the Right Type

If you are building for a specific client, start with a custom app. If you are building a product for the ecosystem, start as a draft app and convert to public when ready. The architecture patterns in this lesson apply to all three types.

The Remix App Template

Shopify's official app scaffold is built on Remix, a full-stack React framework. The template provides everything you need out of the box:

# Create a new Shopify app with the Remix template
shopify app init --template remix

# Project structure after scaffolding
my-shopify-app/
├── app/
│ ├── routes/
│ │ ├── app._index.tsx # Main app page
│ │ ├── app.tsx # App layout with nav
│ │ ├── auth.$.tsx # OAuth callback handler
│ │ ├── auth.login/
│ │ │ └── route.tsx # Login page
│ │ └── webhooks.tsx # Webhook handler
│ ├── shopify.server.ts # Shopify API configuration
│ └── entry.server.tsx # Server entry point
├── extensions/ # App extensions
├── prisma/
│ └── schema.prisma # Database schema
├── shopify.app.toml # App configuration
├── package.json
└── remix.config.js

Key Files Explained

The shopify.server.ts file is the heart of your Shopify integration. It configures authentication, API access, and session storage:

// app/shopify.server.ts
import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
shopifyApp,
DeliveryMethod,
} 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!,
apiVersion: ApiVersion.January25,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL!,
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
webhooks: {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
},
hooks: {
afterAuth: async ({ session }) => {
// Register webhooks after successful OAuth
shopify.registerWebhooks({ session });
},
},
});

export default shopify;
export const apiVersion = ApiVersion.January25;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

App Bridge 4.x

App Bridge is the JavaScript library that connects your app's frontend to the Shopify Admin. Version 4.x is a major overhaul that simplifies the integration model.

What App Bridge Does

  • Navigation: Your app appears inside the Shopify Admin iframe. App Bridge handles navigation, back buttons, and URL synchronization.
  • Authentication: Manages session tokens and ensures API requests are properly authenticated.
  • UI Integration: Provides access to Shopify Admin UI elements like toasts, modals, and context bars.
  • Resource Picker: Built-in components for merchants to select products, collections, and other Shopify resources.

App Bridge in Remix

With the Remix template, App Bridge is configured automatically. The app.tsx layout component wraps your app:

// app/routes/app.tsx
import { Outlet } from "@remix-run/react";
import { AppProvider } from "@shopify/shopify-app-remix/react";
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";

export const links = () => [{ rel: "stylesheet", href: polarisStyles }];

export default function App() {
return (
<AppProvider isEmbeddedApp apiKey={window.ENV.SHOPIFY_API_KEY}>
<Outlet />
</AppProvider>
);
}
App Bridge 4.x Breaking Changes

App Bridge 4.x removed the createApp() function pattern used in earlier versions. If you are migrating from App Bridge 3.x, you no longer create an explicit app instance. Instead, App Bridge initializes automatically when your app loads inside the Shopify Admin. Direct window.shopify calls replace the old app.dispatch() pattern.

Polaris Design System

Polaris is Shopify's design system, providing React components that match the Shopify Admin's look and feel. Using Polaris ensures your app feels native to merchants.

// app/routes/app._index.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
DataTable,
Badge,
Button,
InlineStack,
} from "@shopify/polaris";
import { authenticate } from "../shopify.server";

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

const response = await admin.graphql(`
query {
orders(first: 10, sortKey: CREATED_AT, reverse: true) {
edges {
node {
id
name
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
displayFulfillmentStatus
createdAt
}
}
}
}
`);

const data = await response.json();
return json({ orders: data.data.orders.edges.map((e: any) => e.node) });
};

export default function Index() {
const { orders } = useLoaderData<typeof loader>();

const rows = orders.map((order: any) => [
order.name,
`${order.totalPriceSet.shopMoney.amount} ${order.totalPriceSet.shopMoney.currencyCode}`,
order.displayFulfillmentStatus,
new Date(order.createdAt).toLocaleDateString(),
]);

return (
<Page title="Dashboard" primaryAction={{ content: "Sync Data" }}>
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="300">
<InlineStack align="space-between">
<Text as="h2" variant="headingMd">
Recent Orders
</Text>
<Badge tone="success">Live</Badge>
</InlineStack>
<DataTable
columnContentTypes={["text", "numeric", "text", "text"]}
headings={["Order", "Total", "Fulfillment", "Date"]}
rows={rows}
/>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
Polaris Version

The Remix template ships with Polaris 12.x+. Always check the Polaris migration guide when upgrading, as component APIs change between major versions. The BlockStack and InlineStack components replaced the older Stack component in Polaris 12.

Session Management

Shopify apps must manage sessions for authenticated merchants. The session contains the shop domain, access token, and permission scopes. The Remix template handles this through Prisma-backed session storage.

Session Flow

Session Storage Configuration

// prisma/schema.prisma
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?

@@index([shop])
}
Access Token Security

Never log, expose in client-side code, or store Shopify access tokens in cookies. Access tokens grant full API access to the merchant's store within your app's scopes. Store them encrypted at rest in your database and only access them server-side.

Webhooks and Event-Driven Architecture

Webhooks are HTTP callbacks that Shopify sends to your app when events occur in a merchant's store. They are the backbone of reactive app behavior.

Registering Webhooks

Webhooks are declared in shopify.server.ts and registered automatically after OAuth:

// In shopify.server.ts webhooks config
webhooks: {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
ORDERS_CREATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
CUSTOMERS_DATA_REQUEST: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
CUSTOMERS_REDACT: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
SHOP_REDACT: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
},

Handling Webhooks

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

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

switch (topic) {
case "APP_UNINSTALLED":
if (session) {
await db.session.deleteMany({ where: { shop } });
}
// Clean up app data for this shop
await db.appData.deleteMany({ where: { shop } });
break;

case "ORDERS_CREATE":
// Process new order
await processNewOrder(shop, payload);
break;

case "PRODUCTS_UPDATE":
// Sync product changes
await syncProductUpdate(shop, payload);
break;

case "CUSTOMERS_DATA_REQUEST":
// GDPR: Return customer data
await handleDataRequest(shop, payload);
break;

case "CUSTOMERS_REDACT":
// GDPR: Delete customer data
await handleCustomerRedact(shop, payload);
break;

case "SHOP_REDACT":
// GDPR: Delete all shop data
await handleShopRedact(shop, payload);
break;

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

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

Every Shopify app must handle three GDPR webhooks: CUSTOMERS_DATA_REQUEST, CUSTOMERS_REDACT, and SHOP_REDACT. Your app will fail App Store review if these are not properly implemented. Even if your app does not store customer data, you must acknowledge these webhooks with a 200 response.

Webhook Best Practices

  1. Always respond with 200 quickly -- Process webhook payloads asynchronously. Shopify expects a response within 5 seconds.
  2. Handle idempotency -- Shopify may send the same webhook multiple times. Use the X-Shopify-Webhook-Id header to deduplicate.
  3. Verify HMAC signatures -- The authenticate.webhook() call handles this, but if you are building a custom handler, always verify the X-Shopify-Hmac-SHA256 header.
  4. Monitor delivery failures -- Shopify retries failed webhooks for 48 hours. If your endpoint is consistently failing, Shopify will remove the registration.

Architecture Decision Diagram

Next Steps

Now that you understand the architectural foundation, continue to Admin UI Extensions to learn how to extend the Shopify Admin with custom UI components.