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

【2026年最新版】Shopifyアプリ開発チュートリアル|公式テンプレート×React Routerで作り方を徹底解説

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

このコマンドを実行すると、対話形式でいくつか質問されます。

  • FrameworkReact 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を操作できるようにしてくれるツールです。

Prisma Vector Logo - Download Free SVG Icon | Worldvectorlogo

 

schema.prismaQRCode モデルを追加

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_qrcode
  • migrate 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にアクセスされると、次のことを行います。

  1. QRコードID(:id)が正しいか確認

  2. DBから該当するQRコードを取得

  3. スキャン回数(scans)を +1

  4. QRコードの設定に応じた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/