Shopifyのアプリ開発テンプレートは、React Routerベースで整備され、PolarisはWeb Components(<s-*>) を使うスタイルが標準になっています。
このチュートリアルでは、テンプレートに同梱されている Prisma(SQLite)を更新し、Shopify Admin API を叩きながら、QRコードを作成・一覧表示・スキャン計測するアプリを作ります。
公式ドキュメント:https://shopify.dev/docs/apps/build/build?framework=reactRouter
このチュートリアルでやること
公式チュートリアル上の到達点は次の通りです。
Prisma のDB(テンプレ同梱)を更新する
@shopify/shopify-app-react-routerで認証し、Admin API(GraphQL)で商品情報を取るPolaris Web Components(
<s-page>,<s-section>など)で画面を作る
1. Shopifyアプリ開発を始める|Shopify公式テンプレート(React Router)でアプリを作成
ここでは、Shopify公式が用意しているアプリのひな形を使って、開発を始めます。
認証や管理画面への埋め込み設定などは、最初から含まれています。
アプリを作成する
npm init @shopify/app@latestこのコマンドを実行すると、対話形式でいくつか質問されます。
Framework → React Router を選択
→ Shopify公式が現在推奨している構成です
質問に答えるだけで、アプリ開発に必要なファイル一式が自動生成されます。
作成したアプリを起動する
cd your-app
npm install
npm run devそれぞれの役割は次の通りです。
cd your-app
→ 作成されたアプリのフォルダに移動npm install
→ アプリを動かすために必要なライブラリをインストールnpm run dev
→ 開発用サーバーを起動し、Shopify管理画面とアプリを接続
npm run dev を実行すると、サーバーが起動します。

ターミナルに表示される、Preview URL を開くとShopify管理画面内にアプリが表示されます。
2. Shopifyアプリ開発のDB設計|DB(Prisma)に QRCode テーブルを追加する
このチュートリアルでは QRコードの設定(どの商品に紐づけるか / 行き先 / タイトル / スキャン数など)をDBに保存します。
Shopify公式テンプレート(React Router版)はDB操作に Prisma を使う構成なので、まずは「保存先のテーブル」を定義します。
Prismaって何?
DBの設計図(schema)を書いて
その設計図からDBにテーブルを作り
Node.jsから安全にDBを操作できるようにしてくれるツールです。
schema.prisma に QRCode モデルを追加
prisma/schema.prisma は DBの構造を定義するファイルです。
ここに QRCode を追加すると、「QRCodeというテーブルを作る」という意味になります。
prisma/schema.prisma(完成版)
// 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())
}ここで定義している内容(要点のみ)
id: 連番の主キー(自動で増える)shop: どのストアのデータか(マルチストア対応で必須)title: 管理用の名前productId / productVariantId: どの商品(どのバリアント)用かdestination: QRコードの遷移先の種別(例:商品ページ / チェックアウト など)scans: スキャン回数(初期値0)createdAt: 作成日時(自動で入る)
公式ドキュメントの間違い
公式ドキュメントでは、要点のみのカラムを定義しています。実際には、 firstNameやemailなどのカラムも必要です。
以前までのRemixライブラリでは要点のみで利用できたのですが、React Router v7フレームワークになってから変更されています。
公式ドキュメントはこの変更に対応できていないので注意が必要です。
マイグレーション(DBに反映)
schema.prisma を書いただけではDBは変わりません。
次のコマンドで 実際のDB(SQLite)にテーブルを作成します。
npx prisma migrate dev --name add_qrcodemigrate dev: 開発用にDBへ反映(ローカルDB更新)--name add_qrcode: 「何をした変更か」のメモ(履歴名)
Prisma StudioでDBの中身を確認する
Prismaには DBの中身をブラウザで確認・編集できる管理画面が用意されています。
それが Prisma Studio です。
npm run prisma studioこのコマンドを実行すると、ブラウザで次のURLが開きます。
http://localhost:5555

Prisma Studioでできること
Prisma Studioでは、次のようなことができます。
QRCode テーブルの中身を一覧で確認
実際に保存されたデータを目で見て確認
開発中にデータを手動で追加・修正・削除
「ちゃんとDBに保存されているか?」を即確認
👉 コードを書かずにDBを確認できるのが最大のメリットです。
3. Shopifyアプリのサーバー実装|QRコードのモデル層(サーバー側ロジック)を実装する
ここで作る app/models/QRCode.server.js は、ひとことで言うと 「QRコード機能のサーバー側の中核」です。
DB(Prisma)からQRコード情報を読む/保存する
Shopify Admin API(GraphQL)で商品情報を取得して“表示用に補完”する
QRコード画像(Data URL)を生成する
フォーム入力をバリデーションする
つまり、画面(routes)から見ると「必要な処理を全部ここに頼む」感じになります。
app/models/QRCode.server.js(完成版)
// app/models/QRCode.server.js
import qrcode from "qrcode";
import invariant from "tiny-invariant";
import db from "../db.server";
/**
* 1件取得(DB → Shopify商品情報で補完 → 画像生成まで)
*/
export async function getQRCode(id, graphql) {
const qrCode = await db.qRCode.findFirst({ where: { id } });
if (!qrCode) return null;
return supplementQRCode(qrCode, graphql);
}
/**
* 一覧取得(DB → 各行を補完)
*/
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)));
}
/**
* QRコード画像を生成(Base64のData URL)
* ※ブラウザでそのまま <img src="..."> に貼れる形式
*/
export function getQRCodeImage(id) {
const url = new URL(process.env.SHOPIFY_APP_URL);
url.pathname = `/qrcodes/${id}`;
return qrcode.toDataURL(url.href);
}
/**
* QRコードが飛ぶ「行き先URL」を決める
*/
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}`;
}
}
/**
* Shopify Admin GraphQL API を呼び出して、
* DBのQRCode情報に「表示に必要な商品情報」を足す
*/
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();
// 商品が消えてる(or 権限不足など)場合に備える
const productDeleted = !product?.title;
const productHandle = product?.handle || qrCode.productHandle || "";
return {
...qrCode,
// 画面側で使いやすい形に整える
productDeleted,
productTitle: product?.title || "",
productHandle,
productImage: product?.images?.edges?.[0]?.node?.url || null,
productAlt: product?.images?.edges?.[0]?.node?.altText || "",
// 「遷移先URL」「QR画像」もここで作って渡す
destinationUrl: getDestinationUrl({ ...qrCode, productHandle }),
image: await getQRCodeImage(qrCode.id),
};
}
/**
* 入力チェック(routesのactionから呼ぶ想定)
* エラーは { フィールド名: メッセージ } の形で返す
*/
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;
}
/**
* 作成・更新をまとめて扱う(Upsert風)
* - idがあれば update
* - なければ create
*/
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,
});
}
/**
* 削除
*/
export async function deleteQRCode(id) {
return db.qRCode.delete({ where: { id } });
}関数の解説(超要点だけ)
db.qRCode.findMany(...)は Prisma(SQL) を使ってデータベースに問い合わせしてる部分graphql(query, ...)は Shopify Admin API(GraphQL)で商品を取りに行く部分supplementQRCode()は 「DBだけだと足りない表示情報」をまとめて作る便利関数qrcode.toDataURL()は 画像ファイルを作らずに、文字列としてQR画像を返す(HTMLに直貼りできる)
動画でShopifyアプリ開発チュートリアルを学習!
文章だけだとよくわからないという方は、解説動画をYouTubeにアップロードしているのでそちらをご参考にしてください。
4. Shopifyアプリ管理画面開発|管理画面:QR一覧ページを作る(/app)
このステップでは、管理画面のトップ(/app)に「QRコード一覧」を表示します。
サーバー側(loader)で DB からQRコード一覧を取得
画面側(React コンポーネント)で 一覧テーブル or 空の状態(Empty State)を表示
s-*コンポーネントは Shopifyが用意している管理画面向けUI(見た目が最初から整う)
このファイルが担当すること(全体像)
app/routes/app._index.jsx は「/app のトップページ」です。
loader():ページ表示の前に、ログイン済みの shop を特定してQRコード一覧を取ってくるIndex():loader の結果を受け取り、0件なら空状態、あるなら表を描画する
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)Shopify 管理画面からのアクセスかチェックし、OKなら
session.shop(どのストアか)とadmin.graphql(Admin API を叩くための GraphQL クライアント)
を返してくれます
-
getQRCodes(session.shop, admin.graphql)DB(Prisma)から
shopの QRCode を取るさらに GraphQL で商品情報(タイトルや画像)も補完して返す
→ だから 一覧で「商品名・画像」が表示できるようになります
s-* コンポーネントについて
<s-page>, <s-table> みたいな s-* は、Shopifyが管理画面用に提供しているUIでPolaris Web Component と言います。
余白、文字サイズ、見た目
空の状態(Empty State)
テーブル表示
を “Shopify管理画面のデザイン” と同様に仕上げてくれます。
Polarisを使うと まずUIの細部を気にせず機能を作れるのがメリットです。
app/routes/app._index.jsx(完成版)
// 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);
};空の状態
データがある状態
5. Shopifyアプリ管理画面開発|管理画面:QRコード作成・編集ページ(/app/qrcodes/:id)
このページは 「新規作成」と「編集」を同じファイルで兼ねるのがポイントです。
URL が
/app/qrcodes/newのとき → 新規作成モードURL が
/app/qrcodes/123のとき → 編集モード
React Router の「loader/action」を使うことで、
画面表示(loader) と 保存・削除(action) をこの1ファイルにまとめられます。
何をしているページ?
-
loader
newなら、空の初期データを返す数字なら、DBからQRデータを取り、GraphQLで商品情報も補完して返す(
getQRCode())
-
action
action=deleteなら削除して一覧へ戻すそれ以外は保存(バリデーション→upsert→保存後のURLへリダイレクト)
-
UI
useSubmit()で「Save」「Delete」をフォーム送信っぽく実行する
(<form>を置かなくても、FormDataを投げられる)
app/routes/app.qrcodes.$id.jsx(完成版)
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(); // 追記
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);
}

公式ドキュメントの間違い
公式ドキュメントでは重大なバグがあります。
formタグの onSubmit の中で submit関数を実行している点です。
これは、formタグを使ってサブミット(リクエスト送信)した後に、submit関数でさらにサブミット(リクエスト送信)することになります。
formタグのリクエスト送信には、バックエンド側で安全なリクエストであることを検証するための情報が含まれていません。
そのため、認証エラーが発生してしまいます。
ここでは、formタグのリクエスト送信は、 e.preventDefault() でキャンセルさせ認証情報を含んだリクエストが可能なsubmit関数でリクエスト送信を行う修正をしました。
バックエンドにリクエスト送信する際は、 useSubmitや useFetch を使って認証情報を含むリクエスト送信をする必要があるので、注意してください。
6. Shopifyアプリ公開ページ開発:公開ページ:QRコード画像を表示する(/qrcodes/:id)
このページは ログイン不要(Admin外) です。
QRコードをスキャンした人がShopify管理画面にログインしているとは限らないので、認証なしのルートとして実装します。
実際にアクセスすると、Shopify管理画面の外で以下の画像が表示されると思います。

app/routes/qrcodes.$id.jsx(完成版)
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. Shopifyアプリ処理専用ルート開発:スキャン用ルート:スキャン数を+1して、目的URLへリダイレクト(/qrcodes/:id/scan)
このルートは QRコードがスキャンされた瞬間にアクセスされるURL です。
画面を表示するためのページではなく、処理専用のルートとして使います。
このURLにアクセスされると、次のことを行います。
QRコードID(
:id)が正しいか確認DBから該当するQRコードを取得
スキャン回数(
scans)を +1QRコードの設定に応じたURLへ 即リダイレクト
つまり、
👉 「スキャンを記録してから、ユーザーを商品ページやチェックアウトへ送る」
ためのルートです。
app/routes/qrcodes.$id.scan.jsx(完成版)
// 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));
};
なぜ loader を使っているのか?
loader は ページが表示される前に必ず実行されるサーバー処理
画面を返す必要がないため
👉 「カウント → リダイレクト」だけを行う用途に最適フロント側のJSが不要なので、
👉 スマホ・QRスキャナ・外部ブラウザからのアクセスでも確実に動く
8. 動作確認チェックリスト
ここまで実装できたら、以下を 上から順に確認してください。
すべてOKなら、QRコード管理アプリは正しく動作しています。
管理画面(Shopify Admin内)
/appにアクセスすると、QRコード一覧が表示される「Create QR code」から
/app/qrcodes/newが開く必須項目を入力して保存できる
保存後、自動的に
/app/qrcodes/:id(編集画面)へ遷移する編集画面に QRコード画像と公開URLが表示される
公開ページ(Admin外)
/qrcodes/:idに直接アクセスできる(ログイン不要)QRコード画像が表示される
スキャン動作
/qrcodes/:id/scanにアクセスするとエラーにならない-
アクセスするたびに
scansの値が +1 されるPrisma Studio(
http://localhost:5555)で確認
-
設定した
destinationに応じて商品ページ または
チェックアウト
へリダイレクトされる
よくある確認ポイント
-
QRコードのURLが
{APP_URL}/qrcodes/:id/scanになっているか
SHOPIFY_APP_URLが.envに正しく設定されているかPrisma Studio で
QRCodeテーブルにデータが入っているか
このチェックリストがすべて埋まれば、
「Shopifyアプリ開発 公式チュートリアル(React Router版)」は完成です 🎉
9. 最後に|Shopifyアプリ開発を本気で身につけたい方へ
本記事では、Shopify公式テンプレート(React Router版)を使った最新のShopifyアプリ開発チュートリアルとして、
アプリの初期構築
DB設計(Prisma)
管理画面アプリの実装
公開ページとスキャン処理
実務で使える構成の考え方
までを、一通り解説しました。
この構成を理解できれば、
「Shopifyアプリ開発の全体像」はかなりクリアになっているはずです。
ただし実務では、
商品選択UI(Resource Picker)
Billing(有料アプリ化)
Webhook / Flow / Functions 連携
セキュリティ・運用設計
実案件ベースの設計判断
といった、公式ドキュメントだけでは掴みにくいポイントも多く登場します。
Shopifyアプリ開発を体系的に学びたいなら「テックギーク」
もしあなたが、
Shopifyアプリ開発を仕事にしたい
独学で限界を感じている
実務で通用する設計力を身につけたい
と考えているなら、
Shopifyアプリ開発に特化したオンラインスクール「テックギーク」も選択肢の一つです。
テックギークでは、
実案件レベルのShopifyアプリ設計
最新の公式構成(React Router / Web Components)
「作れる」だけでなく「提案できる」エンジニアになるための考え方
を重視してカリキュラムが設計されています。
本記事の内容を理解できた方であれば、
次のステップとして より実践的なShopifyアプリ開発に進む準備は十分整っています。
👉 【公式サイト】テックギーク - Shopifyアプリ開発:https://techgeek-school.com/