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)へ接続して決済を実現するのか を解説します。