Building Custom Shopify Apps with Remix and Polaris

Complete developer guide to building custom Shopify apps using Remix, Shopify CLI 3, Polaris design system, and App Bridge for embedded admin experiences.

E
ECOSIRE Research and Development Team
|March 19, 202610 min read2.3k Words|

Building Custom Shopify Apps with Remix and Polaris

Shopify's migration to Remix as the default app framework marks a fundamental shift in how production apps are built on the platform. The Remix-based Shopify app template delivers server-side rendering, streaming, nested routing, and native Shopify CLI integration in a single coherent stack — replacing the older Node/Express + React patterns that dominated app development through 2023.

This guide walks through the complete development lifecycle: scaffolding, authentication, GraphQL data fetching, Polaris component integration, webhooks, billing, and the patterns experienced Shopify developers use to ship maintainable, performant embedded apps.

Key Takeaways

  • Shopify CLI 3.x generates a production-ready Remix app with OAuth, session storage, and App Bridge preconfigured
  • The authenticate.admin helper handles the entire OAuth flow — do not implement OAuth manually
  • Remix's loader/action pattern maps perfectly to Shopify's GraphQL request/mutation cycle
  • Polaris components are required for App Store approval — custom UI that ignores Polaris standards fails review
  • Webhooks must be registered via the app configuration, not just API calls, for reliability
  • The Shopify Admin GraphQL API is paginated — always handle cursor-based pagination in data loaders
  • App Bridge provides the chrome-less embedded experience; use useAppBridge hook for navigation
  • Billing API integration is required for any app charging subscription fees

Project Setup and Scaffolding

Start with Shopify CLI 3 to scaffold a properly configured Remix app:

npm install -g @shopify/cli@latest
shopify app create node --template remix
cd your-app-name

The generated scaffold includes:

  • remix.config.js with Shopify-compatible settings
  • shopify.app.toml — your app configuration file (replaces .env for app settings)
  • app/shopify.server.ts — the central authentication and API client configuration
  • app/routes/app.tsx — the root embedded app layout
  • prisma/schema.prisma — SQLite session storage (replace with PostgreSQL for production)

shopify.app.toml structure:

name = "your-app-name"
client_id = "your-api-key"
application_url = "https://your-app-url.com"
embedded = true

[access_scopes]
scopes = "read_products,write_products,read_orders"

[auth]
redirect_urls = ["https://your-app-url.com/auth/callback"]

[webhooks]
api_version = "2025-01"

  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks"

Authentication with authenticate.admin

The shopify.server.ts file exports an authenticate object that handles all authentication concerns. Never implement OAuth manually — the authenticate.admin helper manages the entire flow including:

  • OAuth code exchange
  • Session persistence
  • Token refresh
  • Online vs offline access modes
  • Merchant consent collection

Using authentication in route loaders:

// app/routes/app.products.tsx
import { json } from "@remix-run/node";
import { authenticate } from "../shopify.server";

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

  const response = await admin.graphql(`
    #graphql
    query GetProducts($first: Int!) {
      products(first: $first) {
        nodes {
          id
          title
          status
          priceRangeV2 {
            minVariantPrice {
              amount
              currencyCode
            }
          }
          totalInventory
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  `, {
    variables: { first: 50 }
  });

  const data = await response.json();
  return json({ products: data.data.products });
};

Online vs offline sessions:

  • Offline access: Default for most apps. Session persists independently of merchant login. Used for background jobs, webhooks, scheduled tasks.
  • Online access: Session tied to the logged-in merchant user. Required when you need to act on behalf of a specific user (e.g., tracking which staff member performed an action).

Configure in shopify.server.ts:

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY!,
  apiSecretKey: process.env.SHOPIFY_API_SECRET!,
  scopes: process.env.SCOPES!.split(","),
  appUrl: process.env.SHOPIFY_APP_URL!,
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    unstable_newEmbeddedAuthStrategy: true, // Token Exchange (2024+)
  },
});

Token Exchange (recommended for 2026)

Shopify's newer Token Exchange authentication strategy eliminates OAuth redirects for embedded apps. Users within the Shopify admin do not need to leave and return — the session token from App Bridge is exchanged directly for an API access token server-side. Enable this with unstable_newEmbeddedAuthStrategy: true.


GraphQL Data Fetching Patterns

Shopify's Admin API is GraphQL-first. Master these patterns for production-quality data fetching.

Cursor-based pagination:

export async function getAllProducts(admin: AdminApiContext) {
  let hasNextPage = true;
  let cursor: string | null = null;
  const allProducts = [];

  while (hasNextPage) {
    const response = await admin.graphql(`
      #graphql
      query GetProducts($first: Int!, $after: String) {
        products(first: $first, after: $after) {
          nodes {
            id
            title
            handle
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `, {
      variables: { first: 250, after: cursor }
    });

    const data = await response.json();
    const { nodes, pageInfo } = data.data.products;

    allProducts.push(...nodes);
    hasNextPage = pageInfo.hasNextPage;
    cursor = pageInfo.endCursor;
  }

  return allProducts;
}

Mutations with error handling:

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

  const title = formData.get("title") as string;
  const price = formData.get("price") as string;

  const response = await admin.graphql(`
    #graphql
    mutation CreateProduct($input: ProductInput!) {
      productCreate(input: $input) {
        product {
          id
          title
        }
        userErrors {
          field
          message
        }
      }
    }
  `, {
    variables: {
      input: {
        title,
        variants: [{ price }]
      }
    }
  });

  const data = await response.json();

  if (data.data.productCreate.userErrors.length > 0) {
    return json({
      errors: data.data.productCreate.userErrors
    }, { status: 422 });
  }

  return json({ product: data.data.productCreate.product });
};

Bulk operations for large datasets:

For operations on thousands of records, use the Bulk Operations API rather than paginated queries. Bulk queries run asynchronously and return a JSONL file:

const bulkQuery = await admin.graphql(`
  mutation {
    bulkOperationRunQuery(
      query: """
        {
          products {
            edges {
              node {
                id
                title
                variants {
                  edges {
                    node {
                      id
                      price
                      inventoryQuantity
                    }
                  }
                }
              }
            }
          }
        }
      """
    ) {
      bulkOperation {
        id
        status
      }
      userErrors {
        field
        message
      }
    }
  }
`);

Poll currentBulkOperation until status === "COMPLETED", then download and parse the JSONL from url.


Polaris Design System Integration

Polaris is Shopify's design system for embedded apps. Using Polaris is required for App Store submissions — non-Polaris UI will result in review rejection.

Setting up Polaris in Remix:

// app/root.tsx
import { AppProvider } from "@shopify/polaris";
import "@shopify/polaris/build/esm/styles.css";
import translations from "@shopify/polaris/locales/en.json";

export default function App() {
  return (
    <AppProvider i18n={translations}>
      <Outlet />
    </AppProvider>
  );
}

Common Polaris component patterns for Shopify apps:

// Product listing with DataTable
import {
  Page,
  Card,
  DataTable,
  Button,
  Badge,
  Filters,
  EmptyState
} from "@shopify/polaris";

export default function ProductsPage() {
  const { products } = useLoaderData<typeof loader>();

  const rows = products.nodes.map(product => [
    product.title,
    <Badge tone={product.status === 'ACTIVE' ? 'success' : 'warning'}>
      {product.status}
    </Badge>,
    product.totalInventory,
    `${product.priceRangeV2.minVariantPrice.currencyCode} ${product.priceRangeV2.minVariantPrice.amount}`,
    <Button url={`/app/products/${product.id}`}>Edit</Button>
  ]);

  return (
    <Page
      title="Products"
      primaryAction={{ content: "Add product", url: "/app/products/new" }}
    >
      <Card>
        <DataTable
          columnContentTypes={['text', 'text', 'numeric', 'numeric', 'text']}
          headings={['Title', 'Status', 'Inventory', 'Price', 'Actions']}
          rows={rows}
        />
      </Card>
    </Page>
  );
}

Form handling with Polaris and Remix:

import { Form, useNavigation, useActionData } from "@remix-run/react";
import { TextField, Select, FormLayout, Banner } from "@shopify/polaris";

export default function ProductForm() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <FormLayout>
        {actionData?.errors && (
          <Banner tone="critical">
            {actionData.errors.map(e => <p key={e.field}>{e.message}</p>)}
          </Banner>
        )}
        <TextField
          label="Product title"
          name="title"
          autoComplete="off"
        />
        <TextField
          label="Price"
          name="price"
          type="number"
          prefix="$"
          autoComplete="off"
        />
        <Button submit loading={isSubmitting}>Save product</Button>
      </FormLayout>
    </Form>
  );
}

Webhooks: Registration and Processing

Reliable webhook handling is critical for apps that react to merchant store events.

Registering webhooks via shopify.app.toml:

[[webhooks.subscriptions]]
topics = ["products/create", "products/update", "products/delete"]
uri = "/webhooks"

[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "/webhooks/orders"

Processing webhooks in a Remix route:

// app/routes/webhooks.tsx
import { authenticate } from "../shopify.server";
import db from "../db.server";

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

  switch (topic) {
    case "PRODUCTS_CREATE":
      await db.product.create({
        data: {
          shopifyId: payload.id.toString(),
          title: payload.title,
          shop: shop,
        }
      });
      break;

    case "APP_UNINSTALLED":
      if (session) {
        await db.session.deleteMany({ where: { shop } });
      }
      break;

    default:
      throw new Response("Unhandled webhook topic", { status: 404 });
  }

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

Webhook reliability best practices:

  1. Return a 200 response immediately — process asynchronously via a background queue if the operation is slow
  2. Implement idempotency using the webhook's X-Shopify-Webhook-Id header
  3. Verify the HMAC signature (handled by authenticate.webhook)
  4. Handle retry logic — Shopify retries failed webhooks up to 19 times over 48 hours
  5. Log all webhook payloads for debugging (strip PII before logging)

Billing API: Subscription and Usage Charges

Any app charging fees must use Shopify's Billing API. Bypassing it violates App Store policies.

One-time app purchase:

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

  const { hasActivePayment, appSubscription } = await billing.check({
    plans: ["Professional Plan"],
    isTest: process.env.NODE_ENV !== "production",
  });

  if (!hasActivePayment) {
    await billing.request({
      plan: "Professional Plan",
      isTest: process.env.NODE_ENV !== "production",
    });
  }

  return json({ session });
};

Configure billing plans in shopify.server.ts:

const shopify = shopifyApp({
  // ...other config
  billing: {
    "Professional Plan": {
      amount: 29.99,
      currencyCode: "USD",
      interval: BillingInterval.Every30Days,
    },
    "Enterprise Plan": {
      amount: 99.99,
      currencyCode: "USD",
      interval: BillingInterval.Every30Days,
    }
  }
});

App Bridge and Embedded Navigation

App Bridge is the JavaScript library that manages the embedded iframe experience within the Shopify admin.

Key App Bridge patterns:

import { useAppBridge } from "@shopify/app-bridge-react";
import { Redirect } from "@shopify/app-bridge/actions";

// Navigate to an external URL from embedded context
function ExternalLinkButton() {
  const app = useAppBridge();

  const handleClick = () => {
    const redirect = Redirect.create(app);
    redirect.dispatch(Redirect.Action.REMOTE, {
      url: "https://your-docs-site.com",
      newContext: true
    });
  };

  return <Button onClick={handleClick}>View documentation</Button>;
}

// Toast notifications
import { useToast } from "@shopify/app-bridge-react";

function SaveButton() {
  const { show } = useToast();
  const fetcher = useFetcher();

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data?.success) {
      show("Settings saved");
    }
  }, [fetcher.state, fetcher.data]);

  return (
    <Button
      onClick={() => fetcher.submit(formData, { method: "post" })}
      loading={fetcher.state !== "idle"}
    >
      Save
    </Button>
  );
}

Testing and Local Development

# Start the app in development mode
shopify app dev

# The CLI handles:
# - ngrok tunnel creation for webhook delivery
# - App installation on your development store
# - Hot module replacement for Remix routes
# - Shopify Partner Dashboard app URL updates

Testing GraphQL queries:

Use the Shopify GraphiQL app in your development store admin to test queries before implementing them in code. Access via: your-store.myshopify.com/admin/apps/graphiql.

Unit testing route loaders:

import { describe, it, expect, vi } from "vitest";
import { loader } from "~/routes/app.products";

vi.mock("~/shopify.server", () => ({
  authenticate: {
    admin: vi.fn().mockResolvedValue({
      admin: {
        graphql: vi.fn().mockResolvedValue({
          json: () => Promise.resolve({
            data: {
              products: {
                nodes: [{ id: "gid://shopify/Product/1", title: "Test Product" }],
                pageInfo: { hasNextPage: false, endCursor: null }
              }
            }
          })
        })
      },
      session: { shop: "test-shop.myshopify.com" }
    })
  }
}));

describe("Products loader", () => {
  it("returns product list", async () => {
    const request = new Request("https://test.com/app/products");
    const response = await loader({ request, params: {}, context: {} });
    const data = await response.json();

    expect(data.products.nodes).toHaveLength(1);
    expect(data.products.nodes[0].title).toBe("Test Product");
  });
});

Frequently Asked Questions

Should I use the Remix template or the Node/Express template for new Shopify apps?

Use the Remix template for all new apps. Shopify has standardized on Remix for its app development tooling — the Shopify CLI, documentation, and App Bridge React are all optimized for Remix. The Node/Express template is no longer actively developed and lacks several modern authentication features including Token Exchange. Existing Node/Express apps do not need to migrate immediately, but all new apps should start with Remix.

Can I use a different frontend framework instead of Polaris?

You can use your own design system for the public-facing portions of your app (settings pages on your own domain, landing pages, etc.), but embedded sections within the Shopify admin must use Polaris. App review guidelines explicitly require Polaris for embedded UI. Attempting to replicate Polaris visually with custom components typically results in review rejection due to behavioral and accessibility inconsistencies.

How do I handle Shopify API rate limits in my app?

Shopify's GraphQL Admin API uses a "bucket" rate limiting model. Each shop gives your app 1,000 cost points per bucket, which regenerates at 50 points per second. Monitor the extensions.cost.throttleStatus field in GraphQL responses. For bulk operations (affecting thousands of records), use the Bulk Operations API, which has separate rate limits. Implement exponential backoff with jitter when you receive 429 responses.

What database should I use for my Shopify app in production?

Replace the default SQLite with PostgreSQL for production. The Prisma session storage adapter works with PostgreSQL — change the connection string in your environment variables and update prisma/schema.prisma to use postgresql provider. For apps with high session volumes, consider Redis for session storage instead of PostgreSQL to reduce database load on the hot authentication path.

How do I deploy a Shopify app built with Remix?

Shopify recommends deploying on platforms that support Node.js runtime: Fly.io, Render, Railway, AWS (EC2/ECS), or Google Cloud Run. Vercel and Netlify work for the frontend but cannot run the persistent Node.js server that Shopify's session storage requires. Ensure your deployment platform supports long-running processes for webhook processing and background jobs.


Next Steps

Building a custom Shopify app that handles authentication, GraphQL data management, webhooks, and billing correctly requires deep platform expertise. A misconfigured app fails App Store review, breaks merchant stores, or exposes security vulnerabilities.

ECOSIRE's Shopify app development services cover the full development lifecycle: architecture design, Remix/Polaris implementation, GraphQL API integration, webhook infrastructure, billing setup, App Store submission, and post-launch support.

Discuss your custom Shopify app requirements with our development team.

E

Written by

ECOSIRE Research and Development Team

Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.

Chat on WhatsApp