【2026年最新版】Shopifyアプリ開発 公式チュートリアル(React Router版)徹底解説

[2026 Latest Edition] Shopify App Development Tutorial | A Thorough Explanation of How to Build a Shopify App Using the Official Template and React Router

Shopify's app development templates are based on React Router, while Polaris's standard style is to use Web Components ( <s-*> ) .

In this tutorial, we will update Prisma (SQLite) included in the template and create an app that creates, lists, and scans QR codes by calling the Shopify Admin API.

Official documentation: https://shopify.dev/docs/apps/build/build?framework=reactRouter


💡

What you will do in this tutorial

The official tutorial's goal is as follows:

  • Update the Prisma DB (template included)

  • Authenticate with @shopify/shopify-app-react-router and get product information with Admin API (GraphQL)

  • Create a screen using Polaris Web Components ( <s-page> , <s-section> , etc.)


1. Start developing a Shopify app | Create an app using the official Shopify template (React Router)

Here, we will start development using the app template provided by Shopify .
Authentication and embedding settings in the admin panel are included from the start.

Create an app

 npm init @shopify/app@latest

When you run this command, you will be asked a few interactive questions.

  • Framework → Select React Router → This is the configuration currently recommended by Shopify.

Simply answer the questions and a set of files required for app development will be automatically generated.


Launch the created app

 cd your-app
 npm install
 npm run dev

Their respective roles are as follows:

  • cd your-app
    Go to the created app folder

  • npm install
    Install the libraries required to run the app.

  • npm run dev
    → Start the development server and connect the Shopify admin panel to the app

Run npm run dev and the server will start.

Open the Preview URL displayed in the terminal and the app will appear in your Shopify admin panel .


2. Database design for Shopify app development | Adding a QRCode table to the database (Prisma)

In this tutorial, we will save the QR code settings (which product it is linked to, destination, title, number of scans, etc.) in the database .
The official Shopify template (React Router version) is configured to use Prisma for database operations, so we first define the "destination table."

💡

What is Prisma?

  • Write a DB blueprint (schema)

  • Create a table in the database from the blueprint.

  • This is a tool that allows you to safely operate a database from Node.js.

Prisma Vector Logo - Download Free SVG Icon | Worldvectorlogo

Add QRCode model to schema.prisma

prisma/schema.prisma is the file that defines the DB structure.

Adding QRCode here means "create a table called QRCode."

prisma/schema.prisma (final version)

 // prisma/schema.prisma

 generator client {
 provider = "prisma-client-js"
 }

 datasource db {
 provider = "sqlite"
 url = "file:dev.sqlite"
 }

 model Session {
 id String @id
 shop String
 state String
 
isOnline Boolean @default(false)
 scope String?
 expires DateTime?
 accessToken String
 userId BigInt?
 firstName String?
 lastName String?
 email String?
 accountOwner Boolean @default(false)
 locale String?
 collaborator Boolean? @default(false)
 emailVerified Boolean? @default(false)
 }

 model QRCode {
 id Int @id @default(autoincrement())
 shop String
 title String
 productId String
 productHandle String
 productVariantId String
 destination String
 scans Int @default(0)
 createdAt DateTime @default(now())
 }

Contents defined here (only the main points)

  • id : Primary key with a sequential number (increases automatically)

  • shop : Which store's data is this? ( Required for multi-store support )

  • title : Name for management purposes

  • productId / productVariantId : which product (variant)

  • destination : The type of destination the QR code will take you to (e.g. product page / checkout, etc.)

  • scans : Number of scans (default 0)

  • createdAt : Creation date and time (automatically entered)

💡

Error in the official documentation

The official documentation defines only the essential columns. In reality, columns such as firstName and email are also required.

Previously, the Remix library only allowed you to use the essentials, but this has changed since the React Router v7 framework was released.

Please note that the official documentation does not reflect this change.

Migration (reflecting in DB)

Simply writing schema.prisma will not change the database.
Create a table in the actual DB (SQLite) with the following command:

npx prisma migrate dev --name add_qrcode
  • migrate dev : Reflect to DB for development (update local DB)

  • --name add_qrcode : Note of "what change was made" (history name)

Check the contents of the database with Prisma Studio

Prisma provides an administration screen that allows you to view and edit the contents of the database using a browser .
That's Prisma Studio .

 npm run prisma studio

This command will open the following URL in your browser:

 http://localhost:5555 


💡

What can you do with Prisma Studio?

With Prisma Studio you can:

  • Check the contents of the QRCode table in a list

  • Visually check the actual data stored

  • Manually add, modify, or delete data during development

  • Instantly check whether the data is properly saved in the database

👉The biggest advantage is that you can check the database without writing any code .


3. Server implementation of Shopify app | Implementing the QR code model layer (server-side logic)

app/models/QRCode.server.js we will create here is, in a nutshell, the "core of the server side of the QR code function."

  • Read/save QR code information from DB (Prisma)

  • Get product information using Shopify Admin API (GraphQL) and "complete it for display"

  • Generate a QR code image (Data URL)

  • Validating form input

In other words, from the perspective of the screen (routes), it looks like "please ask here to do all the necessary processing."

app/models/QRCode.server.js (finished version)

 // app/models/QRCode.server.js
 import qrcode from "qrcode";
 
import invariant from "tiny-invariant";
 import db from "../db.server";

 /**
 * 1 item acquired (DB → Complemented with Shopify product information → Image generation)
 */
 export async function getQRCode(id, graphql) {
 const qrCode = await db.qRCode.findFirst({ where: { id } });
 if (!qrCode) return null;

 return supplementQRCode(qrCode, graphql);
 }

 /**
 * List acquisition (DB → complete each line)
 */
 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 (Base64 Data URL)
 * * A format that can be pasted directly into the browser as <img src="...">
 */
 export function getQRCodeImage(id) {
 const url = new URL(process.env.SHOPIFY_APP_URL);
 url.pathname = `/qrcodes/${id}`;

 return qrcode.toDataURL(url.href);
 }

 /**
 
* Decide the "destination URL" where the QR code will jump
 */
 export function getDestinationUrl(qrCode) {
 switch ( qrCode . destination ) {
 case "product":
 return `https://${qrCode.shop}/products/${qrCode.productHandle}`;

 case "checkout":
 return `https://${qrCode.shop}/cart/${qrCode.productVariantId}:1`;

 default:
 return `https://${qrCode.shop}`;
 }
 }

 /**
 * Call the Shopify Admin GraphQL API
 * Add "product information required for display" to the QR Code information in the database
 */
 async function supplementQRCode(qrCode, graphql) {
 const query = `#graphql
 query supplementQRCode($id: ID!) {
 product(id: $id) {
 title
 handle
 images(first: 1) {
 edges {
 node {
 url
 altText
 }
 }
 }
 }
 }
 `;

 const response = await graphql(query, {
 variables: { id: qrCode.productId },
 });

 const {
 data: { product },
 } = await response.json();

 
// Prepare for the case where the product has disappeared (or there is insufficient permission, etc.)
 const productDeleted = !product?.title;

 const productHandle = product?.handle || qrCode.productHandle || "";

 return {
 ...qrCode,

 // Arrange it in a way that is easy to use on the screen
 productDeleted,
 productTitle: product?.title || "",
 productHandle,
 productImage: product?.images?.edges?.[0]?.node?.url || null,
 productAlt: product?.images?.edges?.[0]?.node?.altText || "",

 // Create and pass the "destination URL" and "QR image" here.
 destinationUrl: getDestinationUrl({ ...qrCode, productHandle }),
 image: await getQRCodeImage(qrCode.id),
 };
 }

 /**
 * Input check (assuming it is called from a routes action)
 * Errors are returned in the form { field name: message }
 */
 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";
 } else if (!["product", "checkout"].includes(data.destination)) {
 errors.destination = "Destination must be 'product' or 'checkout'";
 }

 return errors;
 }

 /**
 * Handle creation and updates together (Upsert style)
 * - update if id exists
 * - create if not found
 */
 export async function upsertQRCode({ id, shop, data }) {
 invariant(shop, "shop is required");

 const payload = {
 shop,
 title: data.title,
 productId: data.productId,
 productHandle: data.productHandle || "",
 productVariantId: data.productVariantId,
 destination: data.destination,
 };

 if (id) {
 return db.qRCode.update({
 where: { id },
 data: payload,
 });
 }

 return db.qRCode.create({
 data: payload,
 });
 }

 /**
 * delete
 */
 export async function deleteQRCode(id) {
 return db.qRCode.delete({ where: { id } });
 }

Function explanation (very important points)

  • db.qRCode.findMany(...) is the part that queries the database using Prisma (SQL).

  • graphql(query, ...) is the part that retrieves products using the Shopify Admin API (GraphQL).

  • supplementQRCode() is a convenient function that compiles "display information that is missing from the DB alone"

  • qrcode.toDataURL() returns the QR code as a string without creating an image file (which can be pasted directly into HTML)

Learn Shopify app development tutorials through videos!

If you don't understand the text, please refer to the explanatory video uploaded to YouTube.

4. Shopify App Admin Screen Development | Admin Screen: Create a QR List Page (/app)

In this step, a "QR Code List" will be displayed at the top of the admin screen ( /app ).

  • Get the list of QR codes from the database on the server side (loader)

  • Display a list table or an empty state on the screen (React component)

  • s-* components are the UI for the admin screen provided by Shopify (the appearance is neat from the start)


What this file is responsible for (overall picture)

app/routes/app._index.jsx is the "top page of /app".

  • loader() : Before displaying the page, identify the logged-in shop and retrieve a list of QR codes.

  • Index() : Receives the result of the loader. If there are no results, it will be empty. If there are results, it will draw a table.

Key points about loader

 export async function loader({ request }) {
 
const { admin, session } = await authenticate.admin(request);
 const qrCodes = await getQRCodes(session.shop, admin.graphql);

 return { qrCodes };
 }
  • authenticate.admin(request)

    • Check if the access is from the Shopify admin screen, and if it's OK

    • session.shop (which store) and

    • admin.graphql (GraphQL client for accessing the Admin API)
      It will return

  • getQRCodes(session.shop, admin.graphql)

    • Get shop 's QR Code from the DB (Prisma)

    • Furthermore, product information (title and image) is also completed and returned using GraphQL → This allows you to display "product name and image" in a list.


About s-* components

s-* like <s-page> and <s-table> is a UI provided by Shopify for the admin screen and is called Polaris Web Component.

  • Margins, font size, and appearance

  • Empty State

  • Table View

It will be finished in the same way as the “Shopify admin panel design”.

The first advantage of using Polaris is that you can create functions without worrying about the details of the UI .

app/routes/app._index.jsx (completed version)

 // app/routes/app._index.jsx
 import { useLoaderData, Link } from "react-router";
 import { boundary } from "@shopify/shopify-app-react-router/server";
 import { authenticate } from "../shopify.server";
 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 { qrCodes };
 }

 const EmptyQRCodeState = () => (
 <s-section accessibilityLabel="Empty state section">
 <s-grid gap="base" justifyItems="center" paddingBlock="large-400">
 <s-box maxInlineSize="200px" maxBlockSize="200px">
 <s-image
 
aspectRatio="1/0.5"
 src="https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png"
 alt="A stylized graphic of a document"
 />
 </s-box>

 <s-grid justifyItems="center" maxBlockSize="450px" maxInlineSize="450px">
 <s-heading>Create unique QR codes for your products</s-heading>
 <s-paragraph>
 Allow customers to scan codes and buy products using their phones.
 </s-paragraph>

 <s-stack
 gap="small-200"
 justifyContent="center"
 padding="base"
 paddingBlockEnd="none"
 direction="inline"
 >
 <s-button href="/app/qrcodes/new" variant="primary">
 Create QR code
 </s-button>
 </s-stack>
 </s-grid>
 </s-grid>
 </s-section>
 );

 function truncate(str, { length = 25 } = {}) {
 if (!str) return "";
 if (str.length <= length) return str;
 
return str.slice(0, length) + "…";
 }

 const QRTable = ({ qrCodes }) => (
 <s-section padding="none" accessibilityLabel="QRCode table">
 <s-table>
 <s-table-header-row>
 <s-table-header listSlot="primary">Title</s-table-header>
 <s-table-header>Product</s-table-header>
 <s-table-header>Date created</s-table-header>
 <s-table-header>Scans</s-table-header>
 </s-table-header-row>

 <s-table-body>
 {qrCodes.map((qrCode) => (
 <QRTableRow key={qrCode.id} qrCode={qrCode} />
 ))}
 </s-table-body>
 </s-table>
 </s-section>
 );

 const QRTableRow = ({ qrCode }) => (
 <s-table-row id={qrCode.id} position={qrCode.id}>
 <s-table-cell>
 <s-stack direction="inline" gap="small" alignItems="center">
 <s-clickable
 href={`/app/qrcodes/${qrCode.id}`}
 
accessibilityLabel={`Go to the product page for ${qrCode.productTitle}`}
 border="base"
 borderRadius="base"
 overflow="hidden"
 inlineSize="20px"
 blockSize="20px"
 >
 {qrCode.productImage ? (
 <s-image objectFit="cover" src={qrCode.productImage}></s-image>
 ) : (
 <s-icon size="large" type="image" />
 )}
 </s-clickable>

 <s-link href={`/app/qrcodes/${qrCode.id}`}>
 {truncate(qrCode.title)}
 </s-link>
 </s-stack>
 </s-table-cell>

 <s-table-cell>
 {qrCode.productDeleted ? (
 <s-badge icon="alert-diamond" tone="critical">
 Product has been deleted
 </s-badge>
 ) : (
 truncate(qrCode.productTitle)
 )}
 </s-table-cell>

 <s-table-cell>{new Date(qrCode.createdAt).toDateString()}</s-table-cell>
 
<s-table-cell>{qrCode.scans}</s-table-cell>
 </s-table-row>
 );

 export default function Index() {
 const { qrCodes } = useLoaderData();

 return (
 <s-page heading="QR codes">
 <s-link slot="secondary-actions" href="/app/qrcodes/new">
 Create QR code
 </s-link>

 {qrCodes.length === 0 ? <EmptyQRCodeState /> : <QRTable qrCodes={qrCodes} />}
 </s-page>
 );
 }

 export const headers = (headersArgs) => {
 return boundary.headers(headersArgs);
 };

Empty state

State where data exists


5. Shopify app admin screen development | Admin screen: QR code creation and editing page (/app/qrcodes/:id)

The key point of this page is that you can create new files and edit them in the same file .

  • If the URL is /app/qrcodes/new , create new mode

  • When the URL is /app/qrcodes/123Edit mode

By using React Router's "loader/action",
Screen display (loader) and saving/deleting (action) can be combined into this one file.


What page are you on?

  • loader

    • If new , returns empty initial data

    • If it's a number, retrieve the QR code from the database, complete it with product information using GraphQL, and return it ( getQRCode() ).

  • action

    • If action=delete , delete and return to list

    • Otherwise, save (validate → upsert → redirect to saved URL)

  • UI

    • Use useSubmit() to execute "Save" and "Delete" like a form submission (you can send FormData without placing <form> ).

app/routes/app.qrcodes.$id.jsx (completed version)

 import { useState, useEffect } from "react";
 
import {
 useActionData,
 useLoaderData,
 useSubmit,
 useNavigation,
 useNavigate,
 useParams,
 Link,
 } from "react-router";
 import { authenticate } from "../shopify.server";
 import { boundary } from "@shopify/shopify-app-react-router/server";

 import db from "../db.server";
 import { getQRCode, validateQRCode } from "../models/QRCode.server";

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

 if (params.id === "new") {
 return {
 destination: "product",
 title: "",
 };
 }

 return await getQRCode(Number(params.id), admin.graphql);
 }

 export async function action({ request, params }) {
 const { session, redirect } = await authenticate.admin(request);
 const { shop } = session;

 /** @type {any} */
 const data = {
 ...Object.fromEntries(await request.formData()),
 shop,
 };

 if (data.action === "delete") {
 await db.qRCode.delete({ where: { id: Number(params.id) } });
 return redirect("/app");
 }

 
const errors = validateQRCode(data);

 if (errors) {
 return new Response(JSON.stringify({ errors }), {
 status: 422,
 headers: {
 "Content-Type": "application/json",
 },
 });
 }

 const qrCode =
 params.id === "new"
 ? await db.qRCode.create({ data })
 : await db.qRCode.update({ where: { id: Number(params.id) }, data });

 return redirect(`/app/qrcodes/${qrCode.id}`);
 }

 export default function QRCodeForm() {
 const navigate = useNavigate();
 const { id } = useParams();

 const qrCode = useLoaderData();
 const [initialFormState, setInitialFormState] = useState(qrCode);
 const [formState, setFormState] = useState(qrCode);
 const errors = useActionData()?.errors || {};
 // const isSaving = useNavigation().state === "submitting";
 const isDirty =
 JSON.stringify(formState) !== JSON.stringify(initialFormState);

 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,
 });
 }
 }

 function removeProduct() {
 setFormState({
 title: formState.title,
 destination: formState.destination,
 });
 }

 const productUrl = formState.productId
 ? `shopify://admin/products/${formState.productId.split("/").at(-1)}`
 : "";

 const submit = useSubmit();

 function handleSave(e) {
 e.preventDefault(); // Add
 const data = {
 title: formState.title,
 productId: formState.productId || "",
 productVariantId: formState.productVariantId || "",
 productHandle: formState.productHandle || "",
 
destination: formState.destination,
 };

 submit(data, { method: "post" });
 }

 function handleDelete() {
 submit({ action: "delete" }, { method: "post" });
 }

 function handleReset() {
 setFormState(initialFormState);
 window.shopify.saveBar.hide("qr-code-form");
 }

 // useEffect(() => {
 // if (isDirty) {
 // window.shopify.saveBar.show("qr-code-form");
 // } else {
 // window.shopify.saveBar.hide("qr-code-form");
 // }
 // return () => {
 // window.shopify.saveBar.hide("qr-code-form");
 // };
 // }, [isDirty]);

 useEffect(() => {
 setInitialFormState(qrCode);
 setFormState(qrCode);
 }, [id, qrCode]);

 return (
 <>
 <form data-save-bar onSubmit={(e) => handleSave(e)} onReset={handleReset}>
 <s-page heading={initialFormState.title || "Create QR code"}>
 <s-link
 href="/app"
 slot="breadcrumb-actions"
 
onClick={(e) => (isDirty ? e.preventDefault() : navigate("/app/"))}
 >
 QR Codes
 </s-link>
 {initialFormState.id &&
 <s-button slot="secondary-actions" onClick={handleDelete}>Delete</s-button>}
 <s-section heading="QR Code information">
 <s-stack gap="base">
 <s-text-field
 label="Title"
 details="Only store staff can see this title"
 error={errors.title}
 autoComplete="off"
 name="title"
 value={formState.title}
 onInput={(e) =>
 setFormState({ ...formState, title: e.target.value })
 }
 ></s-text-field>
 <s-stack gap="500" align="space-between" blockAlign="start">
 <s-select
 name="destination"
 label="Scan destination"
 
value={formState.destination}
 onChange={(e) =>
 setFormState({ ...formState, destination: e.target.value })
 }
 >
 <s-option
 value="product"
 selected={formState.destination === "product"}
 >
 Link to product page
 </s-option>
 <s-option
 value="cart"
 selected={formState.destination === "cart"}
 >
 Link to checkout page with product in the cart
 </s-option>
 </s-select>
 {initialFormState.destinationUrl ? (
 <s-link
 variant="plain"
 href={initialFormState.destinationUrl}
 target="_blank"
 >
 Go to destination URL
 
</s-link>
 ) : null}
 </s-stack>
 <s-stack gap="small-400">
 <s-stack direction="inline" gap="small-100" justifyContent="space-between">
 <s-text color="subdued">Product</s-text>
 {formState.productId ? (
 <s-link
 onClick={removeProduct}
 accessibilityLabel="Remove the product from this QR Code"
 variant="tertiary"
 tone="neutral"
 >
 Clear
 </s-link>
 ) : null}
 </s-stack>
 {formState.productId ? (
 <s-stack
 direction="inline"
 justifyContent="space-between"
 alignItems="center"
 >
 <s-stack
 
direction="inline"
 gap="small-100"
 alignItems="center"
 >
 <s-clickable
 href={productUrl}
 target="_blank"
 accessibilityLabel={`Go to the product page for ${formState.productTitle}`}
 borderRadius="base"
 >
 <s-box
 padding="small-200"
 border="base"
 borderRadius="base"
 background="subdued"
 inlineSize="38px"
 blockSize="38px"
 >
 {formState.productImage ? (
 <s-image src={formState.productImage}></s-image>
 ) : (
 <s-icon size="large" type="product" />
 
)}
 </s-box>
 </s-clickable>
 <s-link href={productUrl} target="_blank">
 {formState.productTitle}
 </s-link>
 </s-stack>
 <s-stack direction="inline" gap="small">
 <s-button
 onClick={selectProduct}
 accessibilityLabel="Change the product the QR code should be for"
 >
 Change
 </s-button>
 </s-stack>
 </s-stack>
 ) : (
 <s-button
 onClick={selectProduct}
 accessibilityLabel="Select the product the QR code should be for"
 >
 Select product
 </s-button>
 )}
 
</s-stack>
 </s-stack>
 </s-section>
 <s-box slot="aside">
 <s-section heading="Preview">
 <s-stack gap="base">
 <s-box
 padding="base"
 border="none"
 borderRadius="base"
 background="subdued"
 >
 {initialFormState.image ? (
 <s-image
 aspectRatio="1/0.8"
 src={initialFormState.image}
 alt="The QR Code for the current form"
 />
 ) : (
 <s-stack
 direction="inline"
 alignItems="center"
 justifyContent="center"
 blockSize="198px"
 >
 <s-text color="subdued">
 See a preview once you save
 
</s-text>
 </s-stack>
 )}
 </s-box>
 <s-stack
 gap="small"
 direction="inline"
 alignItems="center"
 justifyContent="space-between"
 >
 <s-button
 disabled={!initialFormState.id}
 href={`/qrcodes/${initialFormState.id}`}
 target="_blank"
 >
 Go to public URL
 </s-button>
 <s-button
 disabled={!initialFormState?.image}
 href={initialFormState?.image}
 download
 variant="primary"
 >
 Download
 </s-button>
 </s-stack>
 </s-stack>
 </s-section>
 </s-box>
 
</s-page>
 </form>
 </>
 );
 }

 export const headers = (headersArgs) => {
 return boundary.headers(headersArgs);
 } 

💡

Error in the official documentation

There is a serious bug in the official documentation.

The submit function is executed within the onSubmit of the form tag.

This means that after submitting (sending a request) using the form tag, you will again submit (send a request) using the submit function.

The form tag request submission does not contain any information that the backend can use to verify that the request is secure.

This causes an authentication error.

Here, we have made the correction that the form tag request sending is canceled with e.preventDefault() and the request is sent using the submit function, which can send a request including authentication information.

Please note that when sending a request to the backend, you need to use useSubmit or useFetch to send a request including authentication information.

6. Shopify App Public Page Development: Public Page: Display QR Code Image (/qrcodes/:id)

This page does not require login (non-Admin) .
Since the person who scans the QR code may not be logged in to the Shopify admin panel, we will implement this as an unauthenticated route .

When you actually access it, you will see the following image outside of the Shopify admin screen.

app/routes/qrcodes.$id.jsx (completed version)

import invariant from "tiny-invariant";
 import { useLoaderData } from "react-router";

 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 {
 title: qrCode.title,
 image: await getQRCodeImage(id),
 };
 };

 export default function QRCode() {
 const { image, title } = useLoaderData();

 return (
 <>
 <h1>{title}</h1>
 <img src={image} alt={`QR Code for product`} />
 </>
 );
 } 

7. Develop a route dedicated to Shopify app processing: Scan route: Increment the number of scans by 1 and redirect to the destination URL (/qrcodes/:id/scan)

This route is the URL that will be accessed the moment the QR code is scanned .
It is used as a route dedicated to processing , not as a page for displaying a screen.

When this URL is accessed, it does the following:

  1. Check if the QR code ID ( :id ) is correct

  2. Get the corresponding QR code from the database

  3. Increase the number of scans scans 1

  4. Immediate redirection to the URL according to the QR code settings

In other words,
👉 "Record the scan and then send the user to the product page or checkout."
This is the route for.

app/routes/qrcodes.$id.scan.jsx (completed version)

 // app/routes/qrcodes.$id.scan.jsx
 import { redirect } from "react-router";
 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));
 }; 
💡

Why use a loader?

  • The loader is a server process that runs before the page is displayed.

  • Since there is no need to return the screen, it is ideal for applications that only perform "count → redirect"

  • Since no front-end JS is required,
    👉 Works reliably even when accessed from a smartphone, QR scanner, or external browser

8. Operation check list

Once you have implemented this, please check the following in order from top to bottom .
If everything is OK, the QR code management app is working properly.

Management screen (in Shopify Admin)

  • When you access /app , a list of QR codes will be displayed.

  • Click "Create QR code" to open /app/qrcodes/new

  • You can enter the required information and save it.

  • After saving, you will automatically be redirected to /app/qrcodes/:id (edit screen)

  • The QR code image and public URL will be displayed on the editing screen.


Public page (outside Admin)

  • /qrcodes/:id can be accessed directly (no login required)

  • The QR code image will be displayed.


Scan Operation

  • When I access /qrcodes/:id/scan , no error occurs.

  • Each time you access it, scans value increases by +1.

    • Check with Prisma Studio ( http://localhost:5555 )

  • Depending on destination you set

    • Product page or

    • You will be redirected to checkout


Frequently asked questions

  • The URL of the QR code is

     {APP_URL}/qrcodes/:id/scan

    Is it becoming

  • Is SHOPIFY_APP_URL set correctly in .env ?

  • Is there data in QRCode table in Prisma Studio?


Once this checklist is complete,
The "Shopify App Development Official Tutorial (React Router Edition)" is complete 🎉

9. Conclusion | For those who want to seriously learn Shopify app development

In this article, we will introduce the latest Shopify app development tutorial using the official Shopify template (React Router version) .

  • Initial app construction

  • DB design (Prisma)

  • Implementing the admin app

  • Public pages and scanning

  • Practical composition ideas

I have explained everything up to that point.

If you understand this configuration,
The “big picture of Shopify app development” should now be pretty clear.

However, in practice,

  • Product selection UI (Resource Picker)

  • Billing (paid app)

  • Webhook / Flow / Functions integration

  • Security and Operational Design

  • Design decisions based on actual projects

There are many points that are difficult to grasp from the official documentation alone , such as:


If you want to learn Shopify app development systematically, try Tech Geek

If you are

  • I want to work as a Shopify app developer

  • I feel limited by self-study

  • I want to acquire design skills that can be used in practice

If you are thinking,
TechGeek , an online school specializing in Shopify app development, is another option.

At Tech Geek,

  • Shopify app design at a real project level

  • Latest official configuration (React Router/Web Components)

  • How to become an engineer who can not only "create" but also "make proposals"

The curriculum is designed with an emphasis on

If you understand the contents of this article,
You're now ready to take the next step and get started with more hands-on Shopify app development .

👉 [Official Website] TechGeek - Shopify App Development: https://techgeek-school.com/