Shu Digital Works

Articles

投稿記事

Top > Articles > プログラミング > Next.js × Railsでつくるステートレスなカート機能の実装方法

Next.js × Railsでつくるステートレスなカート機能の実装方法

プログラミング EC Next.js Rails カート
公開日 更新日
Next.js × Railsでつくるステートレスなカート機能の実装方法

自作アプリで、ECサイトを作っているのですが、
認証自体がAuth.js + 自作JWTでステートレスです。
ショッピングカートも、
リレーショナルデータベースやRedisなどを使わずに
ステートレスに実装しました。

カート、Stripe Connect,Stripe Checkoutを、
vibeコーディングで実装して、
自分の理解が曖昧なので、
AIに解説してもらって、文章化してみたいと思います。

仕様

ローカルストレージに商品のidと個数を保持することで、
カートを実現します。

プラットフォームには複数のショップが出品できます。
複数のショップの商品を同時にカートに入れられません。
カートを空にしてから、入れ直します。

バージョン情報

フロントエンド

  • TypeScript 5.9.2
  • React 19.1.0
  • Next.js 15.5.0
  • Auth.js 5.0.0-beta.29
  • TailwindCSS 4.1.12

バックエンド

  • Ruby 3.3.9
  • Rails 8.0.2.1
  • Postgresql 16.10
  • Docker 28.4.0

Headerにカートを設置する

//略
<div className="text-white font-bold">
  <Link href="/cart">カート</Link>
</div>
//略

/src/components/layouts/Header.tsxがヘッダーのコンポーネントで、
このようにカートのリンクを設置しています。
ログインしなくても、
カートに商品を入れたり、カートを閲覧できます。
決済するためにはログインが必要という設計です。

カート本体

"use client";

import { useState, useEffect } from "react";
import CartItemType from "@/types/cartItem";
import ProductType from "@/types/product";
import { Button } from "@/components/ui/button";
import PageTitle from "@/components/atoms/PageTitle";
import ShopHeader from "@/components/atoms/ShopHeader";
import CartTableBody from "./CartTableBody";
import CheckoutButton from "./CheckoutButton";

export default function CartPage() {
  const [products, setProducts] = useState<ProductType[]>([]);
  const [quantityById, setQuantityById] = useState<Record<number, number>>({});
  const clearCart = () => {
    if (!confirm("カートを空にしますか?")) return;
    localStorage.removeItem("cart");
    localStorage.removeItem("cartShopId");
    setProducts([]);
    setQuantityById({});
  };
  useEffect(() => {
    try {
      const cartJson = localStorage.getItem("cart");
      if (cartJson) {
        const cartItems = JSON.parse(cartJson) as CartItemType[];

        const qMap: Record<number, number> = {};
        for (const item of cartItems) {
          qMap[item.productId] = item.quantity;
        }
        setQuantityById(qMap);

        (async () => {
          const res = await fetch(
            `/api/products?ids=${cartItems
              .map((item: CartItemType) => item.productId)
              .join(",")}`
          );
          const data = await res.json();
          setProducts(data.products);
        })();
      } else {
        console.log("カートは空です");
        setProducts([]);
        setQuantityById({});
      }
    } catch (e) {
      console.error("カートの読み込みに失敗しました:", e);
    }
  }, []);
  const totalCents = products.reduce((sum, p) => {
    const qty = quantityById[Number(p.id)] ?? 0;
    return sum + p.price_including_tax_cents * qty;
  }, 0);
  const shippingCents = products.length > 0 ? 500 : 0;
  const grandTotalCents = totalCents + shippingCents;

  return (
    <div>
      {products.length > 0 && (
        <div>
          {products[0].shop_header_url && (
            <ShopHeader
              shop_header_url={products[0].shop_header_url}
              shop_name={products[0].shop_name}
            />
          )}
        </div>
      )}
      <div className="py-8 inner">
        <PageTitle title="カート" />
        <p className="mt-4 font-bold text-red-500 text-center">
          カートには同じショップの商品だけを入れることができます。
          <br />
          他のショップの商品を購入する場合は、いまのカートを一度空にしてください。
        </p>
        <div className="flex justify-end">
          <Button onClick={clearCart} className="mt-4">
            <span className="font-bold">カートを空にする</span>
          </Button>
        </div>
        {products.length > 0 && (
          <div className="mt-6 overflow-x-auto">
            <h2 className=" text-center text-2xl font-bold">
              {products[0].shop_name}
            </h2>
            <table className="mt-4 w-full min-w-[640px] text-center border-collapse text-sm sm:text-base">
              <thead>
                <tr className="border-b">
                  <th className="p-2">イメージ</th>
                  <th className="p-2">商品名</th>
                  <th className="p-2">税込価格</th>
                  <th className="p-2">数量</th>
                  <th className="p-2">小計</th>
                </tr>
              </thead>
              <CartTableBody products={products} quantityById={quantityById} />
              <tfoot>
                <tr className="border-t">
                  <td className="p-2 text-right font-bold" colSpan={4}>
                    小計
                  </td>
                  <td className="p-2 font-bold">{totalCents}円</td>
                </tr>
                <tr className="border-t">
                  <td className="p-2 text-right font-bold" colSpan={4}>
                    送料
                  </td>
                  <td className="p-2 font-bold">{shippingCents}円</td>
                </tr>
                <tr className="border-t">
                  <td className="p-2 text-right font-bold" colSpan={4}>
                    合計
                  </td>
                  <td className="p-2 font-bold">{grandTotalCents}円</td>
                </tr>
              </tfoot>
            </table>
            <div className="flex justify-center mt-4">
              <CheckoutButton />
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

src/app/cart/page.tsxがカートのページです。
これが本体です
ドメイン直下の/cartがルーティングになります。
これを詳しく見ていきます。

"use client";

クライアントコンポーネントとして扱います。

import { useState, useEffect } from "react";

必要な機能をいろいろimportしています。
ReactからuseStateとuseEffectをimportします。

import CartItemType from "@/types/cartItem";
import ProductType from "@/types/product";

CartItemTypeとProductTypeはTSの型をimportしています。

import { Button } from "@/components/ui/button";
import PageTitle from "@/components/atoms/PageTitle";
import ShopHeader from "@/components/atoms/ShopHeader";

Buttonはshadcn/uiのものを使っています。
PageTitleは自分でコンポーネント化しているものをimportします。
ShopHeaderはヘッダーイメージをコンポーネント化したものをimportします

import CartTableBody from "./CartTableBody";

CartTableBodyはアイテムの量で変化するので共有しています。
詳細は後で説明します。

import CheckoutButton from "./CheckoutButton";

決済のボタンです。
詳細は後で説明します。

export default function CartPage() {

本体の関数を書き始めます。

const [products, setProducts] = useState<ProductType[]>([]);

カート画面で、カートに入れた商品を出すために使います。
useStateを使って初期値は空の配列で、
productsとsetProductsを定義します。
型はProductType[]として、配列で定義します。
ProductTypeは以下のようになっています。

type ProductType = {
  id: string;
  name: string;
  shop_id: string;
  shop_name: string;
  shop_header_url: string | null;
  description: string;
  price_excluding_tax_cents: number;
  price_including_tax_cents: number;
  stock: number;
  image_url: string;
};

export default ProductType;

これは様々な場面で商品の型として共有します。

const [quantityById, setQuantityById] = useState<Record<number, number>>({});

useStateを使ってquantityByIdとsetQuantityByIdを定義します。
初期値は空のオブジェクトで、
型はRecord<number, number>とすることで、
キーもバリューもnumberであることを制限します。
これで商品のidを使って、注文した量を参照できます。

const clearCart = () => {
    if (!confirm("カートを空にしますか?")) return;
    localStorage.removeItem("cart");
    localStorage.removeItem("cartShopId");
    setProducts([]);
    setQuantityById({});
  };

画面にはカートを空にするボタンを設置するので、
押したらこれを呼び出します。
JSのconfirmでポップアップを呼び出して、
キャンセルを押したらそこで処理を終わらせます。
OKを押すと処理が続行します。
localStorageからcartとcartShopIdを削除して
useStateで定義した、productsとquantityByIdを空にします。

useEffect(() => {
    try {
      const cartJson = localStorage.getItem("cart");

useEffectを開始して、tryの中で処理を書きます。
localStorageからcartの情報を取って、cartJsonに入れます。

if (cartJson) {

cartJsonが取れた場合の処理を書きます。

const cartItems = JSON.parse(cartJson) as CartItemType[];

JSON.parseをつかってcartJsonをオブジェクトに変えます。
TSは静的解析なので、localStorageの値に責任持てません。
asで型アサーションを使って、
TSに対してこれはCartItemTypeが配列になったものであることを私たちが保証します。
それを、cartItemsに代入します。

type CartItemType = {
  productId: number;
  quantity: number;
};

export default CartItemType;

CartItemTypeはこのようになっています。

const qMap: Record<number, number> = {};
  for (const item of cartItems) {
    qMap[item.productId] = item.quantity;
  }

qMapをRecordを使って、キーもバリューもnumberであるオブジェクトを定義します。

[
  { productId: 12, quantity: 3 },
  { productId: 55, quantity: 1 },
  { productId: 99, quantity: 4 }
]

cartItemsでは上記のようになっています。

qMap = {
  12: 3,
  55: 1,
  99: 4
};

cartItemsをfor文でqMapに変換するとこのようになります。

setQuantityById(qMap);

変換後のqMapをquantityByIdにセットします。

(async () => {
  const res = await fetch(
    `/api/products?ids=${cartItems
      .map((item: CartItemType) => item.productId)
      .join(",")}`
  );
  const data = await res.json();
  setProducts(data.products);
})();

Next.jsのBFFに対してfetchします。
client componentなので、URLのドメイン部はつけません。

URLのidsの中の処理で、
mapで、cartItemsをitemとして、CartItemTypeを型に使います。
item.productIdを新しい配列にいれます。
新しい配列の中には、カートに入れた商品のidがカンマでjoinされます。

`/api/products?ids=12,55,99`

処理が終わった後は、このような文字列になります。
カートの画面で、カートに入れたアイテムの情報だけ、
バックエンドからデータを取ってきて、並べるための処理です。

Next.jsのBFFでProxyの処理の詳細はこちら

fetchの帰ってきたものがresに入るので、
res.jsonとすることで、オブジェクト化します。

data.productsにカートに入れた商品の情報があるので、
setProductsでセットします。

} else {
  console.log("カートは空です");
  setProducts([]);
  setQuantityById({});
}

ローカルストレージからcartの情報が取れない場合の処理です。

  } catch (e) {
    console.error("カートの読み込みに失敗しました:", e);
  }
}, []);

tryの中でerrorをcatchした場合の処理です。
useEffectの第二引数には何も入れません。

const totalCents = products.reduce((sum, p) => {
    const qty = quantityById[Number(p.id)] ?? 0;
    return sum + p.price_including_tax_cents * qty;
  }, 0);

一つの商品の値段×個数をしたものを小計と呼びます。
TotalCentsは小計を足していったものの合計値です。
送料はまだ入りません。
命名にCentsを入れているのは、最小単位であることを示します。
日本の場合だと、最小単位は1円です。

productsには、カートに入れた商品のidと個数が入っているので、
products.reduceを使います。
reduceは第一引数に処理する関数が入ります。
第二引数に、小計の初期値として0を入れます。

reduceの第一引数の関数の引数のsum, pというのは、
sumにreduceの第二引数の小計の初期値の0が入ります。
products.reduceなので、productsの中身が一つずつpとして扱われます。

const qty = quantityById[Number(p.id)] ?? 0;

p.idを整数化して、個数を取ってきています。

return sum + p.price_including_tax_cents * qty;

値段×個数を小計に足していきます。

最終ループ以外は、returnしたものは次のループに入ります。
最終ループのreturnで、totalCentsに代入されます。

const shippingCents = products.length > 0 ? 500 : 0;

商品が一つ以上あれば、送料を500円としています。
500とハードコーディングしていますが、
変動する場合、環境変数で入れるといいと思います。

const grandTotalCents = totalCents + shippingCents;

totalCentsは小計を足したもので、
更に送料をたしたものをgrandTotalCentsに代入します。
これが最終的な請求額になります。

return (
    <div>

ここからは、描写するJSXの中身です。

{products.length > 0 && (
  <div>
    {products[0].shop_header_url && (
      <ShopHeader
        shop_header_url={products[0].shop_header_url}
        shop_name={products[0].shop_name}
      />
    )}
  </div>
)}

同時にカートに入れられるのは一つのショップの商品だけなので、
もし商品があったら、そのショップのヘッダーイメージを出します。

<div className="py-8 inner">
  <PageTitle title="カート" />
  <p className="mt-4 font-bold text-red-500 text-center">
    カートには同じショップの商品だけを入れることができます。
    <br />
    他のショップの商品を購入する場合は、いまのカートを一度空にしてください。
  </p>
  <div className="flex justify-end">
    <Button onClick={clearCart} className="mt-4">
      <span className="font-bold">カートを空にする</span>
    </Button>
  </div>

ページのタイトルと注意書きです。
その下にカートを空にするボタンがあって、
クリックするとclearCart関数が動いて、カートが空になります。
別のショップの商品をカートに入れるのに使います。

{products.length > 0 && (
    <div className="mt-6 overflow-x-auto">
      <h2 className=" text-center text-2xl font-bold">
        {products[0].shop_name}
      </h2>
      <table className="mt-4 w-full min-w-[640px] text-center border-collapse text-sm sm:text-base">
        <thead>
          <tr className="border-b">
            <th className="p-2">イメージ</th>
            <th className="p-2">商品名</th>
            <th className="p-2">税込価格</th>
            <th className="p-2">数量</th>
            <th className="p-2">小計</th>
          </tr>
        </thead>

カートに中身があると出現します。
ショップの名前とテーブルヘッドをレンダリングします。

<CartTableBody products={products} quantityById={quantityById} />

CartTableBodyコンポーネントにproductsとquantityByIdを渡します。
CartTableBodyの解説は、別のセクションでします。

  <tfoot>
    <tr className="border-t">
      <td className="p-2 text-right font-bold" colSpan={4}>
        小計の合計
      </td>
      <td className="p-2 font-bold">{totalCents}円</td>
    </tr>
    <tr className="border-t">
      <td className="p-2 text-right font-bold" colSpan={4}>
         送料
       </td>
       <td className="p-2 font-bold">{shippingCents}円</td>
    </tr>
    <tr className="border-t">
      <td className="p-2 text-right font-bold" colSpan={4}>
        合計
      </td>
      <td className="p-2 font-bold">{grandTotalCents}円</td>
    </tr>
  </tfoot>
</table>

テーブルのフッターです。
小計の合計と送料と合計が表示されます。

<div className="flex justify-center mt-4">
  <CheckoutButton />
</div>

チェックアウトボタンです。
これを押すと決済処理が走ります。
詳細は別セクションで説明します。

CartTableBody.tsxの解説

import ProductType from "@/types/product";

export default function CartTableBody({
  products,
  quantityById,
}: {
  products: ProductType[];
  quantityById: Record<number, number>;
}) {
  return (
    <tbody>
      {products.map((product) => {
        const qty = quantityById[Number(product.id)] ?? 0;
        return (
          <tr key={product.id} className="border-b align-middle">
            <td className="p-2 text-center">
              <figure className="mx-auto w-16 h-16 rounded overflow-hidden">
                {/* eslint-disable @next/next/no-img-element */}
                <img
                  src={product.image_url}
                  alt={product.name}
                  className="w-full h-full object-cover"
                  decoding="async"
                  referrerPolicy="no-referrer"
                />
              </figure>
            </td>
            <td className="p-2">{product.name}</td>
            <td className="p-2">{product.price_including_tax_cents}円</td>
            <td className="p-2">{qty}</td>
            <td className="p-2">{product.price_including_tax_cents * qty}円</td>
          </tr>
        );
      })}
    </tbody>
  );
}

src/app/cart/CartTableBody.tsxです。
解説します。

import ProductType from "@/types/product";

商品を扱うので、型をimportします。

export default function CartTableBody({
  products,
  quantityById,
}: {
  products: ProductType[];
  quantityById: Record<number, number>;
}) {

CartTableBodyを定義して、オブジェクトとして、
呼び出し元からproductsとquantityByIdを引き取ります。
型もProductType[]とRecord<number, number>で当てます。

return (
  <tbody>
    {products.map((product) => {

JSXを返しますが、tbodyタグを返したら、
productsをproductとしてmapで処理します。

const qty = quantityById[Number(product.id)] ?? 0;

product.idを整数にしてから、
quantityByIdから個数を取得して、qtyに入れます。

return (
  <tr key={product.id} className="border-b align-middle">
    <td className="p-2 text-center">
      <figure className="mx-auto w-16 h-16 rounded overflow-hidden">
        {/* eslint-disable @next/next/no-img-element */}
        <img
          src={product.image_url}
          alt={product.name}
          className="w-full h-full object-cover"
          decoding="async"
          referrerPolicy="no-referrer"
        />
      </figure>
    </td>
    <td className="p-2">{product.name}</td>
    <td className="p-2">{product.price_including_tax_cents}円</td>
    <td className="p-2">{qty}</td>
    <td className="p-2">{product.price_including_tax_cents * qty}円</td>
  </tr>
);

productsをproductとしてmapして処理したものを返しています。
商品が一つあると、一行生成されます。
trタグのkeyにproduct.idをいれます。

一番左に商品の画像を入れています。
Next.jsにはImageタグがありますが、
RailsとS3を使っている場合、相性が悪かったので、
Imageタグを使わず、figureとimgタグで実装しました。
eslintがimgタグ使うなと言ってくるので、
{/* eslint-disable @next/next/no-img-element */}
これで無効にします。

その後ろに、商品名、1個あたりの税込価格、個数、小計を並べています。
この設計だと、カートにいくら商品を入れても、
呼び出し元から受け取る引数はproducts, quantityByIdの2つで済みます。
カートに入れた商品の種類の分だけ、行が生成されます。

決済ボタンの説明

"use client";

import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import CartItemType from "@/types/cartItem";

export default function CheckoutButton() {
  const handlePayment = async () => {
    const sessionRes = await fetch("/api/auth/session", { cache: "no-store" });
    const session = sessionRes.ok ? await sessionRes.json().catch(() => null) : null;
    if (!session?.user?.email) {
      toast.error("先にログインしてください");
      return;
    }
    const cartJson = localStorage.getItem("cart");
    const cart = cartJson ? JSON.parse(cartJson) : [];
    if (cart.length === 0) {
      toast.error("カートに商品がありません");
      return;
    }
    const cartShopId = localStorage.getItem("cartShopId");
    if (!cartShopId) {
      toast.error("カートのショップがありません");
      return;
    }
    const checkoutRes = await fetch("/api/stripe_checkouts", {
      method: "POST",
      body: JSON.stringify({
        cart: cart.map((item: CartItemType) => ({
          product_id: item.productId,
          quantity: item.quantity,
        })),
      }),
    });
    if (!checkoutRes.ok) {
      toast.error("決済に失敗しました");
      return;
    }
    const checkout = await checkoutRes.json();
    window.location.href = checkout.url;
  };
  return (
    <Button onClick={handlePayment}>
      <span className="font-bold">決済する</span>
    </Button>
  );
}

src/app/cart/CheckoutButton.tsxです。
このボタンを押すと、カートの中身で決済処理を呼び出します。
解説します。

"use client";

クライアントコンポーネントとして扱います。

import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import CartItemType from "@/types/cartItem";

ボタンとトーストと、
カートアイテムの型をimportしています。

export default function CheckoutButton() {
  const handlePayment = async () => {

CheckoutButtonを定義して、
メインの処理のhandlePaymentを定義します。

const sessionRes = await fetch("/api/auth/session", { cache: "no-store" });
  const session = sessionRes.ok ? await sessionRes.json().catch(() => null) : null;
  if (!session?.user?.email) {
    toast.error("先にログインしてください");
    return;
  }

client componentなので、auth()を使えません。
api/auth/sessionにfetchするとsessionが帰ってきます。
このエンドポイントはAuth.jsの設定をしたときに、
Auth.jsによって作られたものです。
sessionResに代入して、さらに.json()を使って、
オブジェクト化します。
取ってきたsessionからユーザーのemailを参照して、
なければトーストで、ログインを促します。

const cartJson = localStorage.getItem("cart");
const cart = cartJson ? JSON.parse(cartJson) : [];
if (cart.length === 0) {
  toast.error("カートに商品がありません");
  return;
}

次に、localStorageからcartを取得して、cartJsonに代入します。
それをJSON.parseで、オブジェクト化します。
カートに中身がなければ、トーストでエラーを出します。

const cartShopId = localStorage.getItem("cartShopId");
  if (!cartShopId) {
    toast.error("カートのショップがありません");
    return;
  }

cartShopIdは
同時にカートに入れられるのは一つのショップまでなので、
そのショップのidを取ります。
なければトーストでエラーを出します。

const checkoutRes = await fetch("/api/stripe_checkouts", {
  method: "POST",
  body: JSON.stringify({
    cart: cart.map((item: CartItemType) => ({
      product_id: item.productId,
      quantity: item.quantity,
    })),
  }),
});

if (!checkoutRes.ok) {
  toast.error("決済に失敗しました");
  return;
}

const checkout = await checkoutRes.json();
window.location.href = checkout.url;

api/stripe_checkoutsにPOSTでfetchします。
今回はカートの話なので、
Stripe Checkoutはまた別の記事で書きたいと思います。

カートに入っている商品のidと個数で送信します。
失敗した場合はトーストでエラーを出します。

return (
    <Button onClick={handlePayment}>
      <span className="font-bold">決済する</span>
    </Button>
  );

JSXでボタンを返します。
クリックするとさきほど定義したhandlePaymentが発動します。

Railsのproducts_controller

const res = await fetch(
  `/api/products?ids=${cartItems
   .map((item: CartItemType) => item.productId)
   .join(",")}`

Next.jsでカートを表示するために
Railsのproductsに対して、
クエリパラメーターでidsをわたして、
カートに入れたアイテムだけを取ってきて、
個数と一緒に表示しています。
Railsのproducts_controllerの中身を説明しようと思います。

def index
    if params[:shop_id]
      # 略 クエリパラメーターがshop_idの時の処理
    end

    if params[:ids]
      ids = params[:ids].split(",")
      products = Product
        .where(id: ids)
        .includes(shop: { header_attachment: :blob })
        .with_attached_image
      render json: { products: products.map { |product| product_json(product, is_detail: true) } }, status: :ok
      return
    end

    # 略 クエリパラメーターがないときの処理
  end

クエリパラメータがidsできた場合、
カンマ区切りできているので、
split()を使って、分解してidsという配列に入れます。
Product.where(id: ids)で絞り込んでいます。

.includes(shop: { header_attachment: :blob })
これは関連するショップの画像を、N+1を対処した形で取ってきます。

.with_attached_image
これは商品の画像を、N+1を対処した形で取ってきます。

取ってきたものをproductsに入れています。
商品やユーザーのデータを返すときの構造を共有化したものをシリアライザーとして定義しています。
シリアライザーの詳細は割愛しますが、trueを付ける場合は、
ショップのヘッダーのイメージや、商品の説明文もフロントに返しています。

まとめ

今回の記事では、ローカルストレージを使った 、
ステートレスなカート機能 の仕組みを解説しました。
Next.js 側では、cart の内容をもとに Rails へ必要な商品だけを効率よく取得し、
画面へ表示しています。
決済前だけ Auth.js を通してログイン状態を確認し、
ユーザー体験と安全性を両立させています。
次回は、このカートからどのように
Stripe(Connect / Checkout)へ接続して決済を実現するのか を解説します。

関連記事

2025年9月の学習の振り返り

2025年9月の学習の振り返り

プログラミング

9月は、ポートフォリをコーディングし、アプリの体裁が整ってきた。じっくり休みました。

2025年2月の学習の振り返り

2025年2月の学習の振り返り

プログラミング

2月は主にAWSやネットワークを学習しました。また自分のサイトがほぼ完成しました。

Next.js × Rails|Stripe Connect によるショップのオンボーディング実装

Next.js × Rails|Stripe Connect によるショップのオンボーディング実装

プログラミング

Stripe Connect を使った Next.js × Rails のショップオンボーディング実装を解説します。