Latest Shopify App Development Tutorial - Start Here 👇

【Latest 2026 Edition】Shopify App Development Tutorial|Thorough Explanation of How to Build with Official Template × React Router
A tutorial article explaining the process of building an app with the 2026 React Router framework.
In Shopify Edition Summer’23 in August 2023, the Shopify app development template was changed to the Remix framework.
While it was expected to coexist with Express and Ruby, the template that can be built from Shopify CLI is now exclusively Remix.
With the partnership between Remix and Shopify in November 2022, Remix is becoming virtually a dedicated framework for Shopify development.
Going forward, both Shopify app development engineers and Shopify custom storefront engineers may find themselves in a situation where they have no choice but to develop with Remix.
Therefore, this time, I will explain Shopify's official documentation for the Remix framework.
💡 Estimated completion time
3 hours
What you will learn
In this tutorial, you will learn the following:
-
How to build a Remix app using Shopify CLI
-
How to install an app on a test store
-
How to create products through the app
Prerequisites
-
You have a Shopify Partner account and a test store created.
-
Node.js 16 or later is installed.
-
A Node.js package manager (npm, yarn) is installed.
-
Git 2.28 or later is installed.
Official Documentation
https://shopify.dev/docs/apps/getting-started/build-qr-code-app?framework=remix
Now, let's start with the explanation!
Welcome to the world of Shopify app development! 🌍🌎!!!
Table of Contents
What is a Shopify app?
Shopify apps allow you to build apps that extend the functionality of a store or create unique purchasing experiences for customers. You can also leverage Shopify store data for apps and platforms.
Merchants use Shopify apps to help build their businesses, integrate with external services, and add functionality to the Shopify admin to meet their specific needs.

Source: https://shopify.dev/apps/getting-started
The relationship between the Developer (app developer) and the Merchant (store owner) begins when the store owner installs an app provided by the app developer. The store owner provides additional functionality to their store through the app, improving the customer's purchasing experience.
Shopify App Environment Setup
Setting up the environment for a Shopify app is very simple. This blog will explain it based on macOS, so if you are using Windows, please refer to the Shopify documentation.
Install Shopify CLI
$ brew tap shopify/shopify
$ brew install shopify-cli
$ shopify version
=> 3.24.1
If you already have Shopify CLI installed, please confirm that the version is 3.x. If it's 2.x, update it!
※ If the version is not 2.x
$ brew update
$ brew upgrade shopify-cli
$ brew unlink shopify-cli && brew link --force --overwrite shopify-cli
What is the Remix Framework?
Remix is a framework primarily for developing modern web applications based on React. Officially partnered with Shopify, it can be considered Shopify's official framework. The framework incorporates many modern best practices and patterns in web development.
Key Features
-
Server-Side Rendering (SSR): Remix provides rich tools and configurations to facilitate server-side rendering. This improves performance and SEO.
-
Nested Routing: Remix has file-based routing and supports nested routing. This makes it easy to manage even large-scale applications.
-
Data Prefetching: Users can prefetch (retrieve in advance) necessary data by simply hovering their mouse over a link. This makes page transitions extremely fast.
-
Powerful Data Fetching: Remix provides a mechanism to efficiently load the data required for each route.
-
Code Splitting: Efficient code splitting is possible, which improves performance.
-
TypeScript Support: For developers seeking type safety, TypeScript is supported.
Use Cases
-
High-performance websites and applications
-
Projects that emphasize SEO
-
Large and complex applications
-
Web applications that integrate closely with APIs
Remix has gained significant popularity in a short time, and for developers with React experience, it is a powerful tool for efficiently building advanced web applications.
Building a Shopify App with Remix
First, let's confirm what kind of app we will develop.
In this tutorial, we will develop an app that creates QR codes for products. When a QR code is scanned, customers will be redirected to a product registration purchase screen or a product detail page.
The app will also log each time a QR code is scanned and publish how often it has been used to the merchant.
Through this tutorial, you will learn the following:
Learning content
-
Learn how to manipulate session information and QR code data using Prisma.
-
Learn how to develop applications, user authentication, and data queries using the Remix framework with the @shopify/shopify-app-remix package.
-
Learn Shopify-compliant UI using Polaris components and app design guidelines.
-
Learn how to improve app display speed and UX using Shopify App Bridge.
Now that you have an overview, let's dive into development.
First, enter the following command in your terminal to install the Remix version of the Shopify app template.
$ npm init @shopify/app@latest
? Your project name?: Project name
? Get started building your app:
✔ Start with Remix (recommended)
In the latest Shopify template, only the Remix framework can be selected.
Also, access to the database (SQLite) has changed from writing SQL to using Prisma.
First, let's verify that the installed app starts.
$ cd Project name
$ npm run dev
? Create this project as a new app on Shopify?
> **(y) Yes, create it as a new app**
(n) No, connect it to an existing app
? App name:
> **remix-tutorial (App name)**
? Which store would you like to use to view your project?
> **※ Please be sure to select a test store**
? Have Shopify automatically update your app's URL in order to create a preview experience?
┃ Current app URL
┃ • <https://shopify.dev/apps/default-app-home>
┃
┃ Current redirect URLs
┃ • <https://shopify.dev/apps/default-app-home/api/auth>
> **(y) Yes, automatically update**
(n) No, never
When you first launch, you will be asked to install the store, but be sure to select a test store. The selected store will not be available as a production environment.
This time, I created a new app "remix-tutorial" in the partner dashboard and set it to automatically update the app URL to the preview URL.
Click on the Preview URL displayed in the terminal.

Clicking it will display the app installation screen in your test store's admin panel.

Once installed, the app will appear as follows:

The installed app has links to various documents and a "Generate a Product" button to create a new demo product.
Clicking the button will display the executed GraphQL API code and register a new product.

This completes the initial setup of the app! 🎉
Great job.
Now, let's get right into the main tutorial!
1. Adding a QR Code Data Model to the Database
💡 Note!
Prisma and @prisma/client modules are installed at version 4. The latest version is 5, but please be sure to use version 4.
To store QR codes, we need to add a table to the database included in the template.
The specifications for the QRCode table we will create are as follows:
-
id: The primary key for the table. -
title: The name of the QR code specified by the app user. -
shop: The shop that owns the QR code. -
productId: The product to which this QR code belongs. -
productHandle: Used to create the QR code's destination URL. -
productVariantId: Used to create the QR code's destination URL. -
destination: The destination of the QR code. -
scans: The number of times the QR code has been scanned. -
createdAt: The date and time the QR code was created.
The QRCode model includes keys that the app uses to retrieve Shopify product and variant data.
At runtime, additional product and variant properties are retrieved and used to set the UI.
Normally, we would create a table directly on PostgresQL or MySQL, but this time we will use Prisma, an ORM (Object-relational mapping).
Open prisma/schema.prisma and define the QRCode model.
//...omitted
model QRCode {
id Int @id @default(autoincrement())
title String
shop String
productId String
productHandle String
productVariantId String
destination String
scans Int @default(0)
createdAt DateTime @default(now())
}
With Prisma, you can create tables by defining models without writing SQL directly.
To create the table, enter the following command:
$ npm run prisma migrate dev -- --name add-qrcode-table
This completes the table creation!
It's so simple that if you want to confirm it was actually created, enter the following command:
$ npm run prisma studio
Then, the browser will automatically open. If it doesn't open, access http://localhost:5555/.

You can confirm that two models are registered.
Click on the QRCode model.

You can see that the table column names are displayed.
In this way, Prisma allows you to easily check which models are registered, check table columns, and perform data saving, updating, and deleting operations.
It's very attractive to be able to use it easily without installing any tools!
2. Fetching QR Code and Product Data
After creating the database, add code to retrieve data from the table.
Retrieve QR code data from the database and supplement product information via the GraphQL API.
First, create a model.
However, the model here is slightly different from the model in Prisma's schema definition.
It refers to the M in the MVC (Model-View-Controller) model, and defines the layer that manages the application's business logic and data.
Database interactions, data processing, and validation are handled in this layer.
Create a /app/models folder and create a file named QRCode.server.js within it to house the model for fetching and validating QR codes.
$ mkdir app/models$ touch app/models/QRCode.server.js
One thing to note here is the file naming convention.
The filename xxx.server.js instructs Remix that the code within the file should not be included in the browser.
In other words, it "designates the file to operate only on the server-side."
This file is necessary for writing information that you don't want to expose, such as database requests and private keys.
Be careful not to accidentally write private keys to the frontend! (It's a common mistake...)
Next, install the QR code generation module qrcode and tiny-invariant to allow loaders to easily throw errors.
$ npm i qrcode tiny-invariant
Write the code to retrieve QR codes in the created app/models/QRCode.server.js file.
import db from "../db.server";
import qrcode from "qrcode";
import invariant from "tiny-invariant";
// Get QRCode data from DB and return QRCode data, product information, and destination URL.
export async function getQRCode(id, graphql) {
const qrCode = await db.qRCode.findFirst({ where: { id } });
if (!qrCode) {
return null;
}
return supplementQRCode(qrCode, graphql);
}
// Return multiple QRCodes
export async function getQRCodes(shop, graphql) {
const qrCodes = await db.qRCode.findMany({
where: { shop },
orderBy: { id: "desc" },
});
if (qrCodes.length === 0) return [];
return Promise.all(
qrCodes.map((qrCode) => supplementQRCode(qrCode, graphql))
);
}
// Generate QR code image data from URL
export function getQRCodeImage(id) {
const url = new URL(`/qrcodes/${id}/scan`, process.env.SHOPIFY_APP_URL);
return qrcode.toDataURL(url.href);
}
// Generate product detail page URL or checkout URL
export function getDestinationUrl(qrCode) {
if (qrCode.destination === "product") {
return `https://${qrCode.shop}/products/${qrCode.productHandle}`;
}
const match = /gid:\/\/shopify\/ProductVariant\/([0-9]+)/.exec(qrCode.productVariantId);
invariant(match, "Unrecognized product variant ID");
return `https://${qrCode.shop}/cart/${match[1]}:1`;
}
// Get product information from QRCode data via GraphQL API, and return it including the destination URL.
async function supplementQRCode(qrCode, graphql) {
const response = await graphql(
`
query supplementQRCode($id: ID!) {
product(id: $id) {
title
images(first: 1) {
nodes {
altText
url
}
}
}
}
`,
{
variables: {
id: qrCode.productId,
},
}
);
const {
data: { product },
} = await response.json();
return {
...qrCode,
productDeleted: !product?.title,
productTitle: product?.title,
productImage: product?.images?.nodes[0]?.url,
productAlt: product?.images?.nodes[0]?.altText,
destinationUrl: getDestinationUrl(qrCode),
image: await getQRCodeImage(qrCode.id),
};
}
// Implement QRCode validation
export function validateQRCode(data) {
const errors = {};
if (!data.title) {
errors.title = "Title is required";
}
if (!data.productId) {
errors.productId = "Product is required";
}
if (!data.destination) {
errors.destination = "Destination is required";
}
if (Object.keys(errors).length) {
return errors;
}
}
Let's check the role of each function defined here.
-
getQRCode, getQRCodes: Functions to get a single QR code and multiple QR codes. The specific return value will be the return value ofsupplementQRCode. -
getQRCodeImage: Returns a Base 64 encoded QR code image using the qrcode package. -
getDestinationUrl: When a QR code is scanned, customers are directed to one of two places: the product detail page or checkout with the product added to their cart. We create a function to conditionally construct this URL based on the destination selected by the merchant. -
supplementQRCode: TheQRCode tabledoes not contain product information. Therefore, we use a function to query product title, URL, and alt text of the first featured product image using the Shopify Admin GraphQL API to get product information. It also uses the created getDestinationUrl and getQRCodeImage functions to get the QR code image of the destination URL and returns an object containing QR code information and product information. -
validateQRCode: Validation function for title, product ID, and destination.
With this, the creation of models related to QRCode is complete!
3. Create a QR Code form
We will create a form that allows app users to manage QR codes.
To create this form, we will use Remix routes, Polaris components, and App Bridge.
First, create the QR code form page app.qrcodes.$id.jsx.
$ touch app/routes/app.qrcodes.\$id.jsx
Here, the backslash before the $ sign is used to prevent $id from disappearing from the file name.
By the way, there is a special reason why the file name contains "." (dot) and "$".
The Remix framework uses file-based routing.
File-based routing means that if the URL is /app/users, the app.users.jsx file is loaded, and the file name matches the routing.
In other words, "." (dot) means separating layers.
In Remix framework rendering, "." (dot) not only separates layers but also loads parent layout routes.
In the previous example, if the URL is app/users, both the app.jsx file and the app.users.jsx file are loaded.
This makes it possible to separate roles for each file, such as implementing user authentication in app.jsx and implementing a profile page in app.users.jsx.
On the other hand, "$" means dynamic segments.
In this tutorial, if the id parameter is new, it will be used as a new QR code creation page. If it is a QR code ID such as 1, it will be used as an editing page.
This means that Remix matches any value in the URL for that segment and provides it to the app.
First, we will implement the server-side in the app.qrcodes.$id.jsx file.
import { json } from "@remix-run/node";
import { getQRCode } from "~/models/QRCode.server";
import { authenticate } from "~/shopify.server";
export async function loader({ request, params }) {
const { admin } = await authenticate.admin(request);
if (params.id === "new") {
return json({
destination: "product",
title: "",
});
}
return json(await getQRCode(Number(params.id), admin.graphql));
}
The loader function is a special function for Remix.
When an HTTP request comes in, the loader function is executed on the server side.
This means that server-side descriptions are made within the loader function.
There is also a very similar function called the action function.
The action function is executed on the server side when the HTTP method is not GET, and it is executed before the loader function. This function is used for things like form submissions, not page loads.
Inside the loader function, user authentication is performed. Also, the JSON returned to the browser is conditionally branched depending on the value of the id parameter.
To return JSON, the json function is used. The json function has the following capabilities:
json(JSON data)
↓ Same meaning
new Response(stringified JSON data, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"status": 200
}
});
Next, we will build the front-end (View).
First, we will build the code to manage the form's state.
// Add above loader function
import { useActionData, useLoaderData, useNavigate, useNavigation, useSubmit } from "@remix-run/react";
import { useState } from "react";
// ... omitted
// Front-end construction (state management only)
export default function QRCodeForm() {
const errors = useActionData()?.errors || {};
const qrCode = useLoaderData();
const [formState, setFormState] = useState(qrCode);
const [cleanFormState, setCleanFormState] = useState(qrCode);
const isDirty = JSON.stringify(formState) !== JSON.stringify(cleanFormState);
const nav = useNavigation();
const isSaving =
nav.state === "submitting" && nav.formData?.get("action") !== "delete";
const isDeleting =
nav.state === "submitting" && nav.formData?.get("action") === "delete";
const navigate = useNavigate();
async function selectProduct() {
const products = await window.shopify.resourcePicker({
type: "product",
action: "select", // customized action verb, either 'select' or 'add',
});
if (products) {
const { images, id, variants, title, handle } = products[0];
setFormState({
...formState,
productId: id,
productVariantId: variants[0].id,
productTitle: title,
productHandle: handle,
productAlt: images[0]?.altText,
productImage: images[0]?.originalSrc,
});
}
}
const submit = useSubmit();
function handleSave() {
const data = {
title: formState.title,
productId: formState.productId || "",
productVariantId: formState.productVariantId || "",
productHandle: formState.productHandle || "",
destination: formState.destination,
};
setCleanFormState({ ...formState });
submit(data, { method: "post" });
}
}
Let's briefly explain the special functions of Remix.
-
useLoaderData: Retrieves the return value of the
loaderfunction. -
useActionData: Retrieves the return value of the
actionfunction. -
useNavigation: Manages the page state (e.g., submitting).
-
useNavigate: Manages page transitions.
-
useSubmit: Returns a function to submit a form.
Let's explain what this form's state management code means.
-
errors: Gets the return value of thevalidateQRCodefunction if the user has not filled in all QR code form fields. -
formState: The state of the input form. -
cleanFormState: The initial state of the form. Assigns the return value ofuseLoaderData. -
isDirty: Determines if the form has been changed. -
isSaving,isDeleting: Manages the page state using theuseNavigationfunction.
Pay attention to the following code in the selectProduct function.
await window.shopify.resourcePicker
This uses the AppBridge resourcePicker function.
The resourcePicker function provides a search-based interface that allows users to search for and select one or more products, collections, or product variants, and returns the selected resources to the app.

This function can be obtained from window.shopify.
This makes it easy to get registered product information.
AppBridge was previously managed by components. In the Remix framework, component management has been deprecated and defined as a window.shopify function. window.shopify is imported at the time of the initial template.
Now, we will finally create the form layout.
We will design it using Polaris components.
Polaris is the design system for Shopify administrators. Using Polaris components ensures that the UI is accessible, responsive, and consistent with Shopify Admin.
The form layout will be as follows:
```javascript //...omitted // Add above the loader function import { Card, Bleed, Button, ChoiceList, Divider, EmptyState, HorizontalStack, InlineError, Layout, Page, Text, TextField, Thumbnail, VerticalStack, PageActions, } from "@shopify/polaris"; //...omitted export default function QRCodeForm() { //...omitted return (4. Listing QR Codes
To allow app users to navigate to QR codes, we will list the QR codes on the app's home page. To load QR codes, we use the `loader` function in the app's index route `app._index.jsx`. ```javascript //Initial code export const loader = async ({ request }) => { const { session } = await authenticate.admin(request); return json({ shop: session.shop.replace(".myshopify.com", "") }); }; //Changed code import { getQRCodes } from "~/models/QRCode.server"; export async function loader({ request }) { const { admin, session } = await authenticate.admin(request); const qrCodes = await getQRCodes(session.shop, admin.graphql); return json({ qrCodes, }); } ``` We use the `getQRCodes` function, defined in `app/models/QRCode.server.js` earlier, to return a JSON list of all QR codes. At this time, we will change the display content depending on whether there is any QR code data or not. The display when there is no QR code data is as follows: ```javascript const EmptyQRCodeState = ({ onAction }) => (Allow customers to scan codes and buy products using their phones.
5. Adding a public QR code route
All pages developed so far have handled requests from the store administration screen. Here, we will publish QR codes using a public URL, allowing customers to scan them. When a customer scans a QR code, the scan count increases, and the customer is redirected to the destination URL. We will create a public page. ```javascript $ touch app/routes/qrcodes.\$id.jsx ``` It renders the QR code title and image data. ```javascript import { json } from "@remix-run/node"; import invariant from "tiny-invariant"; import { useLoaderData } from "@remix-run/react"; import db from "../db.server"; import { getQRCodeImage } from "~/models/QRCode.server"; export const loader = async ({ params }) => { invariant(params.id, "Could not find QR code destination"); const id = Number(params.id); const qrCode = await db.qRCode.findFirst({ where: { id } }); invariant(qrCode, "Could not find QR code destination"); return json({ title: qrCode.title, image: await getQRCodeImage(id), }); }; export default function QRCode() { const { image, title } = useLoaderData(); return ( <>{title}
Redirecting customers to the destination URL
When a QR code is scanned, we redirect the customer to the destination URL. We can also increment the QR code's scan count to reflect how many times it has been used. First, to create a scan route, we create a public route that handles QR code scans. ```javascript $ touch app/routes/qrcodes.\$id.scan.jsx ``` Using the `loader` function, we count QR code scans on the server side and, if successful, redirect to the destination URL. ```javascript import { redirect } from "@remix-run/node"; import invariant from "tiny-invariant"; import db from "../db.server"; import { getDestinationUrl } from "../models/QRCode.server"; export const loader = async ({ params }) => { invariant(params.id, "Could not find QR code destination"); const id = Number(params.id); const qrCode = await db.qRCode.findFirst({ where: { id } }); invariant(qrCode, "Could not find QR code destination"); await db.qRCode.update({ where: { id }, data: { scans: { increment: 1 } }, }); return redirect(getDestinationUrl(qrCode)); }; ``` If the QR code is not found, an error is thrown by the `invariant` function. If found, Prisma is used to increment the `scans` column in the QRCode table. This completes the QR Code generation app! Let's check if it works correctly!Operational Check
The contents of the operational check are as follows:
-
Generate QR code
-
Download QR code
-
Open public URL of QR code
-
Edit QR code
-
Delete QR code
-
Scan QR code
-
Confirm that the destination is the checkout screen
-
Confirm that the scan count is 1
Deploying to Fly.io
Finally, we will deploy this app to Fly.io.
If you don't have an account with fly.io, please register in advance.
Once you are logged into the Fly.io dashboard, install the flyctl command.
$ brew install flyctl
Deploy the app using the flyctl command.
$ flyctl launch
? Choose an app name (leave blank to generate one): App name
? Select Organization: Fly.io Account name (personal)
? Choose a region for deployment: Tokyo, Japan (nrt)
$ flyctl deploy
Upon successful deployment, a domain (https://app-name.fly.dev) will be issued.
Environment variables are registered using the flyctl command. This can also be done on the Fly.io dashboard.
$ fly secrets set SHOPIFY_APP_URL=https://app-name.fly.dev SCOPES=write_products NODE_ENV=production SHOPIFY_API_KEY=xxxxxxxxx SHOPIFY_API_SECRET=xxxxxxxx
From the Shopify partner dashboard, go to the target app's "App settings" and change the "App URL" and "Allowed redirect URL" domains to the Fly.io domain.

This completes the deployment and setup! !
While Fly.io can use SQLite, it's best to avoid SQLite in a production environment and use PostgreSQL, MySQL, DynamoDB, etc.
The current settings are still on SQLite, so as a challenge, try changing to PostgreSQL.
This concludes the Shopify app development tutorial using the Remix framework!
Good job!
Finally
For Shopify app development, a minimum understanding of the following is required:
-
Understanding of the Remix framework
-
Understanding of the Polaris library
-
Understanding of OAuth2.0 authentication method
-
Understanding of REST API & GraphQL API
-
Understanding of Partner Agreement
And truly various other learnings are needed.
This tutorial only provides an overview of the whole process.
Here are some common problems you might encounter:
If you try to retrieve customer information with the REST API, even if you add read_customers to the SCOPE, you won't be able to retrieve it due to a permission error.
This is because, as stated in the Partner Agreement, prior application and approval are required to retrieve customer information.
Even if you manage to retrieve customer information, you'll then realize that you can only retrieve a maximum of 50 items.
This is because the REST API rate limit by default only allows retrieving 50 items, and pagination development is required.
As such, developing Shopify apps truly requires a wide range of knowledge.
Leave a Comment