Building your first Shopify app sounds intimidating, but Shopify's modern developer tooling makes it genuinely possible to have something running in 30 minutes. Shopify CLI scaffolds a complete project, handles authentication, sets up a tunnel to your local machine, and drops you into a React-based app with Polaris (Shopify's design system) pre-installed. Your job is just to understand the pieces and build on top of them. This tutorial walks you through doing exactly that: scaffolding an embedded Shopify app with Remix, adding a product list, and writing an action that applies a tag to selected products.
What you'll build
A simple embedded app (meaning it runs inside the Shopify admin, not as a standalone website) that lists your store's products fetched from the GraphQL Admin API, lets you select one, and adds the tag "featured" to it via a mutation. This is small, but it touches every part of the app platform: authentication, data fetching, data mutation, and a Polaris UI.
Before you start
- Node.js 20 or higher. Run
node --versionto check. If you're behind, install it from nodejs.org. - A Shopify Partner account. Free at partners.shopify.com. This is where you register your app and manage its credentials.
- A development store. In Partner Dashboard → Stores, create a new "Development store". This is a free sandbox store that works just like a real Shopify store but can never go live and process real payments.
Install Shopify CLI and scaffold a new app
Shopify CLI is the command-line tool that does most of the heavy lifting. Install it globally with npm, then scaffold a new app:
npm install -g @shopify/cli @shopify/theme
shopify app initExpected outcome: The CLI asks for a project name and which template to use. Select the Remix template. It creates a directory with a complete Remix app including authentication, session storage, and Polaris already configured.
Install the app to your dev store
Navigate into your new project folder and start the dev server:
The CLI opens a browser window asking you to log in to your Partner account and select your development store. It creates a local HTTPS tunnel (via Cloudflare Tunnel or ngrok) so Shopify can reach your local server, then registers the app and installs it to your dev store automatically.
cd my-shopify-app
npm install
shopify app devExpected outcome: Terminal shows a local URL (usually http://localhost:3000) and your app is installed in the dev store. You'll see the app in the Shopify admin sidebar.
Open the admin and see the default app
In your Shopify admin (admin.shopify.com/store/your-dev-store), look in the left sidebar for your app name under "Apps". Click it. Shopify loads your app in an embedded iframe—the Remix server is running on your machine and the admin is proxying it through App Bridge. You should see the default Shopify app template page with a "Generate a product" button.
This confirms the authentication is working: the app loaded inside the admin, which means Shopify verified the session and your app code received a valid access token.
Expected outcome: You can see the default scaffolded app UI inside the Shopify admin.
Tour the code: routes, auth helpers, and Polaris
Open the project in your editor. The key files to understand:
app/shopify.server.ts— configures the@shopify/shopify-app-remixpackage with your app credentials, scopes, and session storage. You export anauthenticateobject from here.app/routes/app._index.tsx— the main page of your app. Theloaderfunction runs on the server, callsauthenticate.admin(request)to get the admin API client, and returns data. The default export is the React component that renders in the browser.- Polaris components (imported from
@shopify/polaris) are pre-wired:Page,Card,Buttonetc. These auto-match the Shopify admin's look and feel.
Expected outcome: You understand where authentication happens, where to add server-side data fetching, and what Polaris is.
Build a route that lists products via GraphQL
Create a new file app/routes/app.products.tsx. In the loader, call authenticate.admin(request) to get admin, then use admin.graphql() to run the products query.
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Page, Card, ResourceList, ResourceItem, Text } from "@shopify/polaris";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(
`#graphql
query GetProducts {
products(first: 20) {
nodes {
id
title
tags
}
}
}`
);
const { data } = await response.json();
return json({ products: data.products.nodes });
};
export default function ProductsPage() {
const { products } = useLoaderData();
return (
<Page title="Products">
<Card>
<ResourceList
items={products}
renderItem={(product) => (
<ResourceItem id={product.id} url={`/app/tag?id=${product.id}`}>
<Text variant="bodyMd" fontWeight="bold">{product.title}</Text>
<Text variant="bodySm">Tags: {product.tags.join(", ") || "none"}</Text>
</ResourceItem>
)}
/>
</Card>
</Page>
);
}Expected outcome: Navigating to /app/products in your embedded app shows a list of your dev store's products with their current tags.
Add an action that tags a product
Create app/routes/app.tag.tsx. In Remix, an action is a server-side function that runs when a form is submitted. We'll use the tagsAdd mutation from the GraphQL Admin API.
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Page, Card, Button, Banner } from "@shopify/polaris";
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const productId = formData.get("productId");
const response = await admin.graphql(
`#graphql
mutation TagProduct($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) {
node { id }
userErrors { field message }
}
}`,
{ variables: { id: productId, tags: ["featured"] } }
);
const { data } = await response.json();
if (data.tagsAdd.userErrors.length > 0) {
return json({ error: data.tagsAdd.userErrors[0].message });
}
return redirect("/app/products");
};
export const loader = async ({ request }) => {
const url = new URL(request.url);
return json({ productId: url.searchParams.get("id") });
};
export default function TagPage() {
const { productId } = useLoaderData();
const actionData = useActionData();
return (
<Page title="Add 'featured' tag">
<Card>
{actionData?.error && (
<Banner status="critical">{actionData.error}</Banner>
)}
<Form method="post">
<input type="hidden" name="productId" value={productId} />
<Button submit variant="primary">Tag as Featured</Button>
</Form>
</Card>
</Page>
);
}Expected outcome: Clicking a product in the list navigates to the tag page; clicking "Tag as Featured" adds the "featured" tag and redirects back to the product list.
Deploy to Shopify-managed hosting
Shopify offers free managed hosting for apps built with the Remix template. Run shopify app deploy and follow the prompts. Shopify builds and hosts your app on its infrastructure. The app URL changes from your local tunnel to a stable production URL. You can also deploy to Vercel or Fly.io by setting the SHOPIFY_APP_URL environment variable and pointing your app's URLs in the Partner Dashboard to your Vercel/Fly domain.
shopify app deployExpected outcome: Your app is running on a stable URL, no longer dependent on your local machine.
What's next
You've covered the full lifecycle of an embedded Shopify app: scaffold, authenticate, query, mutate, deploy. From here, you can explore the Polaris component library (polaris.shopify.com) for more sophisticated UI patterns, Shopify Functions for customizing checkout logic without hosting a server, and billing APIs if you want to charge merchants for your app. The Shopify developer documentation at shopify.dev is the best next reference—specifically the "Build apps" section.
Understanding how authentication works in the Remix template
One of the first questions beginners ask is: "How does my Remix route know who's logged in and which store they're from?" The answer is the authenticate helper that the scaffold generates. Every server-side loader or action in your app calls await authenticate.admin(request) as its first line. This call does several things at once: it verifies the session token from the Shopify admin, looks up the stored access token for that store, and returns an admin object pre-configured with the store's credentials so you can immediately make API calls.
If the session is invalid or expired, authenticate.admin automatically redirects the user to re-authorize. You don't have to write any redirect logic yourself. This is one of the biggest productivity wins of the Remix template: authentication is handled at the framework level, not in your individual route code.
Session storage is configured in app/shopify.server.ts. By default it uses a SQLite database via Prisma—the database file is created automatically in your project directory. For production, you'd swap this for a hosted database (PostgreSQL on Railway or Supabase, for example) by updating the database URL in your environment variables. The session storage interface is generic, so the rest of your app code doesn't change when you switch databases.
Working with the Polaris design system
Polaris is Shopify's open-source React component library, used to build the Shopify admin itself. When you build your embedded app with Polaris components, it looks and feels like a native part of the admin—which dramatically improves trust and usability. The Remix template installs Polaris and configures it via the AppProvider component that wraps your app's root layout.
The most-used Polaris components for app development are: Page (the full-page container with a title and optional action buttons), Card (a white content box with padding), Layout and Layout.Section (for two-column grids), DataTable (for tabular data), ResourceList (for lists of items like products or orders), Modal (for confirmations and forms), and Banner (for alerts and success messages). All of these accept accessibility attributes by default—you get keyboard navigation and screen-reader compatibility without extra work.
Polaris has detailed documentation and a component explorer at polaris.shopify.com. Every component page shows props, usage guidelines, and do/don't examples. When you're unsure how to build a UI pattern in your app, check Polaris first—there's usually a component that already solves it.
Handling errors and userErrors in mutations
A pattern that trips up beginners: Shopify GraphQL mutations almost always return a userErrors array alongside the result data. These are application-level errors (e.g., "Product not found", "Tag is too long") as opposed to HTTP-level errors. The HTTP response will be 200 OK even when there are user errors—Shopify treats them as successful API calls that returned validation messages. You must check userErrors explicitly in every mutation response, as we did in the tagsAdd mutation in Step 6.
For network-level errors (your server can't reach Shopify, or the access token is invalid), the admin.graphql() call will throw an exception. Wrap your API calls in try/catch blocks and return meaningful error messages to your UI. A well-handled error state—"Couldn't connect to your store. Please try again."—is far better than a blank page or an unhandled server crash.
Frequently asked questions
- Can I build a Shopify app without using React or Remix?
Yes. Shopify's app platform is language and framework agnostic—you can build a Shopify app in Python (Django/Flask), Ruby on Rails, Go, or any other language that can serve HTTP and make API calls. The Remix template is just the fastest way to get started if you know JavaScript/TypeScript. If you prefer another stack, read the Shopify app authentication documentation at shopify.dev and implement OAuth manually, as covered in the companion tutorial on this site.
- What's the difference between a public app and a custom app?
A public app can be installed by any Shopify merchant—it goes through OAuth. A custom app is built for a single specific store; the admin generates API credentials directly inside that store's Settings → Apps page, skipping the OAuth flow entirely. If you're building for your own store only, a custom app is simpler. If you want to list your app in the Shopify App Store or let multiple stores install it, you need a public app.
