360
349

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】最新のNext.js+NextAuth.js+prisma+microCMSでECサイト作ってみた【フルスタックアプリケーション】

Last updated at Posted at 2024-01-09

はじめに

皆さんこんにちは、mamiなのだ!
今回はバックエンドは作らずにNextAuth.jsやprisma、microCMSなどを利用してNext.jsでECサイトを作成してみたので、その方法や手順などを公開しつつ、認証周りや大型開発案件でも採用されるstorybookなどについても解説していこうと思うのだ!

フロントを勉強し始めた初学者さんや、フロントがメインではないバックエンドエンジニアの方に向けて、丁寧に解説を挟みながら書いていくので「へ〜フロントってこんな感じのことやってるんだ〜」と思ってくれたら嬉しいのだ!

ちなみにこの記事は丁寧に解説しすぎて死ぬほど長くなってしまったので、気になる部分だけ読んでみても良いのだ!
フロントガチ初心者の方は読んでるだけでも勉強になると思うので、暇な時にのんびり読むことをお勧めするのだ!

対象読者

  • フロントを勉強し始めた初学者さん
  • フロントがメインではないバックエンドエンジニアの方
  • NextAuth.jsやprisma、microCMSなどの使い方を学びたい方
  • stripeの導入方法を知りたい方
  • 最新のNext.jsのServerComponentやClientComponentについての理解を深めたい
  • app Routerの実装方法を学びたい
  • 猫が大好きな方

この記事のゴール

  • 要件に合った技術選定ができるようになる
  • 最新のNext.jsの実装方法が分かる
  • 周辺ライブラリの使い方が理解できる
  • 多分猫が大好きになれる!

どんなアプリ作ったのだ?

早速だが、作ったアプリはデプロイしているので気になる人は下記を実際に見てみるといいのだ!
完結に説明すると、猫缶を販売するECサイトのクローンみたいなのを作ったのだ!

決済はstripeを使っていて、NextAuth.jsで認証機能を実装しているのだ。

ついうっかりDBを消してしまったのでAPI周りが動かないかもしれないのだ
サイト自体は閲覧可能なのでご了承くださいなのだ!気が向いたら復旧するのだ!

トップ画像のキャッチフレーズにもある通り、全ての肉球に愛を込めて作ったのだ!僕は猫が大好きなのだ!

image.png

細かい使用技術などは後ほど丁寧に解説していくのだ!
コードを見てみたい方は下記に公開しているので見てみるといいのだ!

開発過程

それでは早速開発過程を追っていきながら、それぞれのライブラリなどの使い方や、技術の説明なども挟みつつ解説していくのだ!

1.要件定義

出来るだけミニマムスタイルで開発したかったので、機能は下記のように必要最低限にしたのだ。

機能一覧

  • GitHubでのログイン・ログアウト機能。
  • 商品の新規投稿機能
  • 商品の編集、削除機能
  • 決済機能
  • 過去に購入済み商品はラベルで表示
  • マイページでユーザー情報の表示
  • マイページから過去に購入した商品を参照
  • 検索機能

2.技術選定

技術選定は大きな悩みポイントなのだ。
ここをミスると後々の実装で苦労するので、プロジェクトの要件に合った技術選定をする必要があるのだ。間違っても適当に決めてはいけないのだ!

①言語

まずは大前提となる言語についてはTypeScriptを選定したのだ。
最近ではもはやデファクトスタンダードになりつつあるのは、言うまでもないと思うのだ。
念の為導入理由を挙げるとするならば、下記

  • 型チェックによって早期にバグを検知できる。それによって修正コストも抑えることができる。
  • 型があることで、プログラムの可読性の向上。エディターの補完機能を活かすことができ、コーディングの効率も向上が見込める。
  • 導入する上でのデメリットが見当たらないこと(強いて言えば学習コスト)。
    と言ったところなのだ。

②フレームワーク

続いてフレームワークについてなのだ。
結論から先に言うと、Next.js(React)を選定したのだ。
理由としては下記

  • TypeScriptとの親和性
  • ライブラリが豊富(選択肢が豊富)
  • 単方向データバインディングによるシンプルな実装が可能
  • コミュニティの活発度
  • Vueに比べて後方互換性が高いため、長期での運用保守に向いている。Vue2~Vue3のような大きなBreaking Changeが少ない

細く訂正
「大きなBreakingChangeが少ない」点に関しては、 2 → 3 の時の移行が大変だったことを反省し、今後のアップデートではなるべく後方互換性を残すようにしていくことを明言されているようです。
なので今後はこの点での心配はあまりしなくても良さそうですね!

Another thing to note is there is no plan for big breaking changes for the foreseeable future. Acknowledging the challenges users faced during the v2 to v3 transition, we want to have a better long term upgrade story for Vue going forward.

比較対象としてVue.js(Nuxt.js)も候補に上がりましたが、上記にも記した
TypeScriptとの親和性、ライブラリの豊富さ、コミュニティの活発度などの主に3点で最終的にReactを選定する結果となったのだ。

ちなみにReact単体ではなくNextにした理由としては、SSRやISRを使用したかった点が大きかったのだ。
ぶっちゃけSSRかSSGを利用せずにSPAで構成するのであれば、Reactで十分だと思うのだ。
あとはNext.jsであればルーティングを自動でやってくれるので、そこは開発する上で効率的だとも思うのだ。

③ReactとVueの使い分け

さて、ここで少し本題とはずれますが、ReactとVueの使い分けについて触れていきたいのだ。
この2つのフレームワーク以外にも、最近特に注目されている Svelte や Solid については、まだまだ採用プロジェクト数も少なく、情報もあまり多くはないので一旦置いておくのだ。

ReactとVueの使い分けについて個人的な持論なのだが、以下で判別するといいと思うのだ。

React

  • 長期での運用を見据えているか
  • 規模が比較的大きく、複雑なアプリケーションである
  • 要件に合ったライブラリ選定をしたい
  • チームメンバーにReact経験者がいる、あるいはキャッチアップしながらの開発が可能

Vue

  • とりあえず小規模から早く開発を進めていきたい
  • チームメンバーの技術スタックがVueに偏っている
  • 選定コストを抑えて早く開発に着手したい
  • どうしても初期学習コストをかけたくない

もう少し詳しく説明すると、
Reactを採用する大きなメリットは「複雑なアプリケーションでも比較的シンプルな実装が可能・ライブラリ等の自由度の高さ」であり、反対にVueの大きなメリットは「初期学習コストや選定コストを抑えて、小規模からスピード感のある開発が可能」であることだと思うのだ!

それ故に個人開発〜中規模案件くらいまではVueを使用した方が良い場合も多いと思うのだ。
逆に大規模案件や、要件が複雑な場合、長期での運用を見据えているなどはシンプルな実装が可能なReactを選択することで、後々の負債も溜まりにくく開発効率を維持しながらスケールアップすることが可能なのだ。

これはVueはEasy、ReactはSimpleとしばしば言われることにも関係してくるのだ。

もちろんVueでも大規模案件で採用されているケースもあるのだ。
例えばZOZOやnoteなどでもVueが使われていると聞くので、大規模な開発にも耐えうるのだが、Reactのようなシンプルなデータ構造のものと比べると、どうしても規模が大きくなるにつれて負債も貯まっていくような印象なのだ。

とはいえ、学習コストが比較的低いことや、バックエンド開発者にとって理解しやすかったり、選定コストを抑えて早く開発に着手したい場合などはReactよりもスピード感を持った実装が可能となるのだ。
Reactは自由度が高い分、考えないといけないことも多くなってしまうのだ。

だからVueであろうがReactであろうが、それぞれの特性やメリデメを理解した上で適切な選択をする必要があるのだ!
もしあなたが技術選定を任せられた時には、上記のことを少し思い出して、プロジェクトにあった最適な選択をしてほしいのだ!

下記記事もおすすめなので、VueとReactの違いについてもっと深掘りたい場合は、見てみるといいのだ。

ちなみにですが、フロントエンドに関心がある方はnpm trendsなどで定期的に動向を追っていくことをお勧めなのだ。
やはりフロントエンドはモダンさが命なので、最新の技術にもアンテナはることは大切なのだ。

直近のReactやVue、Svelteなどを比較すると下記の画像のような感じだったのだ。
こうして見ると、まだまだReactがフロントエンドを引っ張っていく気がするのだ!

image.png

④CSSフレームワーク

意外と意見が分かれがちなCSSどうするのか問題なのだ。
結論から言うと、今回はTailwind CSSを採用したのだ!

Tailwind CSSとは

ユーティリティファーストのCSSフレームワークなのだ!

  • ユーティリティファーストとは
    • 予め用意されたCSSクラスをHTMLに当てるだけでスタイルを適用させていく手法
  • ユーティリティが提供されるフレームワークなのでコンポーネントは付属しない
    • BootstrapやMaterial-UIといったフレームワークはデザインされたコンポーネントが付属
    • TailwindCSS用のコンポーネントのライブラリはあるので使いたい場合は追加

メリット

  • コードの記述量を削減できる
  • クラス名を考える必要が減る
  • 柔軟にカスタマイズできる
  • レスポンシブ対応が容易
  • コードがシンプルになるため、メンテナンス性の向上にも繋がる

Nextを使うならEmotion推しだったのだが、今回は自由度の高いC向けのデザインというよりは、シンプルで開発効率を重視した要件だったので、Tailwindが最適だと思い選定したのだ。
一番使ってて楽だったのは、クラス名を考える必要が減ることが一番大きかったのだ。
地味に最適なクラス名を考える時間が苦痛なのと、効率的にもあまり良くはないと思っていたのですごく楽だったのだ。

⑤認証機能

今回は Next.js を使用することが決定したので、認証機能はより Next.js と相性の良い NextAuth.js を採用することにしたのだ。

まずそもそも NextAuth.js とは Next.js 向けに作られたライブラリで、認証やセッション管理を簡単に行うことができるのだ。GoogleやTwitter、GitHubなど、OAuthを使った認証サービスが利用できるように設計されているのだ

Next.js アプリケーションに簡単かつ迅速に認証機能を追加することができ、尚且つ TypeScript との統合がサポートされており、型安全なコードを記述することができるのだ。

⑥デプロイ

Next.jsを使うのでデプロイは一番相性の良いVercelにしたのだ!

Next.js の公式ホスティングサービスなだけあって、GitHubにプッシュするだけでCI/CDを構築しなくても自動デプロイしてくれたり、SSRやSSGなんかにも当然のように対応しているのでめちゃくちゃ便利なのだ!

⑦ORM

ORMについては、オブジェクト関係マッピング - Wikipediaにて以下の説明がされているのだ。

オブジェクト関係マッピング(英: Object-relational mapping、O/RM、ORM)とは、データベースとオブジェクト指向プログラミング言語の間の非互換なデータを変換するプログラミング技法である。オブジェクト関連マッピングとも呼ぶ。実際には、オブジェクト指向言語から使える「仮想」オブジェクトデータベースを構築する手法である。

簡単に言うと、プログラミング言語のオブジェクトで定義したメソッドで、SQLを書かずにデータベースの操作が可能なツールということなのだ。
データベースの操作や管理を仲介する役割を持っているのだ。
また、データベースの作成やマイグレーションといった操作も可能なのだ。

そして今回使用するのはPrismaなのだ。

PrismaはNode.jsを対象としたオープンソースORMなのだ。
型安全なデータベースアクセスが特徴で、TypeScriptとの相性が良いのだ。

今回はバックエンドは作成せずに、このORMを使用してDB操作を行ってくのだ!

え、バックエンド用意しないでDB操作とかできるの?と思うかもしれないが、Next.jsではサーバー機能も備わっていて、一つのサーバで両者が動いているのだ。具体的には、Next.jsのプロジェクトを立ち上げると、appフォルダ内にapiというサブフォルダがあり、その中のファイルは全てバックエンドとなるのだ。

公式にもある通り、以下のように

async function doSQL(a: string, d: string) {
  const likeCondition = `%${a}%`;
  if (d === 'desc') {
    const {
      rows,
    }: {
      rows: QueryResultRow &
        {
          id: number;
          no: string;
          name: string;
          pos: string;
        }[];
    } =
      await sql`SELECT * FROM players WHERE no ILIKE ${likeCondition} OR name ILIKE ${likeCondition} OR pos ILIKE ${likeCondition} ORDER BY no desc;`; // descの部分を動的に埋め込むことはできません
    return rows;
  } else {
    // ... 略
  }
}

のようなSQL文を書くことだって出来るのだ!
ちなみに生SQLを書くと危険じゃない?と思うかもなのだが、Prepareのようなセキュリティ対策が自動的に適用されるので、埋め込みミスのようなSQLインジェクションのリスクは無いようにできているのだ。

とはいえ現実的に生SQLを書いていくと言うより今回のように Prisma などと組み合わせて使用することになると思うのだ。

今回はその方法も解説していくのだ。

⑧商品投稿

記事を投稿したり、編集したりするのに今回はmicroCMSを採用したのだ!

microCMSとは
日本製のHeadless CMS(ヘッドレスCMS)なのだ。
ヘッドはViewのことなで「ヘッドレス」=「Viewなし」と言い換えることができるのだ。

Viewがないとフロントエンドの選択が自由になるのだ。(React.js、Vue.jsなどのフレームワークで書いてもいいし、ネイティブアプリにしてもいいし、そもそもどんなデザインにするのかも自由なのだ!)

日本製でドキュメントも豊富なのもいいところなので、今回利用して見ることにしたのだ。

⑨決済機能

Stripeとはオンラインの決済代行サービスで、APIを利用してさまざまな種類の決済を受け付けることができるプラットフォームです。金融機関の口座やデジタルウォレットにも連携できるため、利便性が高い決済手段なのだ。

今回は実際に販売するわけではないが、テスト運用も出来るので実際に触ってみるといいのだ!

4.DB設計

テーブルは下記5テーブルのみなのだ!
ちなみに下記で出てる外部キー制約とは、「FOREIGN KEY制約(外部キー制約)とは親テーブルと子テーブルの2つのテーブル間でデータの整合性を保つために設定される制約です」のことなのだ。
詳しくは下記を読んでみると理解できると思うのだ!

Account Table

Field Type Key Description
id TEXT PK アカウントID
userId TEXT FK ユーザーID(User.idへの外部キー)
type TEXT アカウントタイプ
session_state TEXT セッション状態

Session Table

Field Type Key Description
id TEXT PK セッションID
sessionToken TEXT セッショントークン
userId TEXT will ユーザーID(User.idへの外部キー)
expires TIMESTAMP(3) 有効期限

User Table

Field Type Key Description
id TEXT PK ユーザーID
name TEXT 名前
email TEXT メールアドレス
emailVerified TIMESTAMP(3) メール認証日時
image TEXT 画像URL

Product Table

Field Type Key Description
id TEXT PK 購入ID
userId TEXT ユーザーID(User.idへの外部キー)
productId TEXT 商品ID
createdAt TIMESTAMP(3) 作成日時(デフォルト:現在時刻)

VerificationToken Table

Field Type Key Description
identifier TEXT 識別子
token TEXT トークン
expires TIMESTAMP(3) 有効期限

5.実装

では、諸々の準備が整ったところでいよいよ実装をしていくのだ!

①Next.jsのセットアップ

まずはNext.jsのセットアップなのだ!
下記コマンドでインストールしていくのだ。
ちなみに今回は最新のNext.jsを使用するので、app routerを使っていくのだ。
色々と聞かれるが全部エンターでいいのだ。

npx create-next-app@latest

上記完了したらGitHubにinitial commitしておくのだ。

②Vercel Postgresのセットアップ

続いてVercel Postgresのセットアップをしながらついでに最初のデプロイもやっておくのだ。
下記にアクセスして、ユーザー登録がまだの方は最初にやっておくのだ。

デプロイ手順は下記の手順で行うのだ。

  1. サインアップ画面での右上にある"New Repository"をクリックする
  2. 画面左の"Import Git Repository"から、公開したいプログラムを選択する
  3. "Select Vercel Scope"で、公開元となるアカウントを選択する
  4. "Project Name"を指定し、右下の"Deploy"をクリックする

こんな画面が出てきたらデプロイ成功なのだ!めちゃくちゃ簡単なのだ!

image.png

上記完了したらpostgresの設定をするのだ。
Storage から「Create database」で postgres を選択してDB作成。

Connect Project で先ほどのプロジェクトを選択。下記のような画面が出てきたらOKなのだ。

image.png

そしてNext.jsのプロジェクトに下記コマンドでvercelインストールと、envファイルの作成を行うのだ。

// vercelインストール
npm i -g vercel

vercel link

// envファイルの作成
vercel env pull .env.development.local
// パッケージのインストール
npm install @vercel/postgres

postgresのenvファイルをコピーして先ほど作成したenvファイルにDBの接続情報を貼り付けるのだ。

image.png

これで postgres のセットアップは完了なのだ!

③ヘッダーコンポーネントの作成

では前準備が整ったところで、早速画面を作っていくのだ!

appディレクトリの配下にcomponentsを作成し、その中にHeader.tsxを作成するのだ。

app/components/Header.tsx
import Image from "next/image";
import Link from "next/link";
import React, { ReactNode } from "react";
import { getServerSession } from "next-auth";
import { nextAuthOptions } from "../lib/next-auth/options";
import { PiCatLight } from "react-icons/pi";
import { FaCat } from "react-icons/fa6";

type NavLinkProps = {
  href: string;
  children: ReactNode;
};

const NavLink = ({ href, children }: NavLinkProps) => (
  <Link
    href={href}
    className="flex items-center text-black hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium group"
  >
    {children}
  </Link>
);

const Header = async () => {
  const session = await getServerSession(nextAuthOptions);
  const user = session?.user;

  return (
    <header className="bg-gray-100 shadow-lg">
      <nav className="flex items-center justify-between p-4">
        <Link
          href={"/"}
          className="text-xl font-bold flex items-center text-black"
        >
          <FaCat className="w-8 h-8" />
          <span className="ml-2 font-serif">猫缶.com</span>
        </Link>

        <div className="flex-grow mx-4 max-w-md">
          <input
            type="text"
            placeholder="人気の猫缶を検索...."
            className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <div className="flex items-center gap-1">
          <NavLink href="/">
            <PiCatLight className="transition-transform duration-300 group-hover:-translate-x-1" />
            <span className="ml-0.5 transition-transform duration-300 group-hover:scale-110">
              ホーム
            </span>
          </NavLink>
          <NavLink href={user ? "/profile" : "/api/auth/signin"}>
            <PiCatLight className="transition-transform duration-300 group-hover:-translate-x-1" />
            <span className="transition-transform duration-300 group-hover:scale-110">
              {user ? "プロフィール" : "ログイン"}
            </span>
          </NavLink>
          {user && (
            <NavLink href={"/api/auth/signout?callbackUrl=/"}>
              <PiCatLight className="transition-transform duration-300 group-hover:-translate-x-1" />
              <span className="transition-transform duration-300 group-hover:scale-110">
                ログアウト
              </span>
            </NavLink>
          )}
          {user ? (
            <Link
              href={`/profile`}
              className="rounded-full overflow-hidden border border-gray-300"
            >
              <Image
                width={40}
                height={40}
                alt="profile_icon"
                src={user?.image || "/default_icon.png"}
                className="rounded-full"
              />
            </Link>
          ) : (
            <Image
              width={40}
              height={40}
              alt="profile_icon"
              src={"/default_icon.png"}
              className="rounded-full"
            />
          )}
        </div>
      </nav>
    </header>
  );
};

export default Header;

するとこんな感じのヘッダーが完成するのだ。
ところどころアニメーションも加えてオシャレな感じにしているのだ。

image.png

デザインやCSSに関しては本題とズレるので特に触れないのだ。
ログインやログアウトは後ほど解説するので、今はヘッダーのデザインが作れたらそれでいいのだ。

④フッターコンポーネントの作成

続いてフッターコンポーネントを作成するのだ。
同じくcomponents配下にFooter.tsxを作成。そしてそこに下記のように記述するのだ。

app/components/Footer.tsx
import Link from "next/link";
import React from "react";
import { FaCat } from "react-icons/fa6";

const Footer = () => {
  return (
    <footer className="bg-gray-100 shadow-inner mt-10">
      <nav className="flex flex-col items-center justify-between p-4 text-sm">
        <div className="flex mb-4">
          <Link href={"/"} className="flex items-center text-black">
            <FaCat className="w-6 h-6" />
            <span className="ml-2 font-serif">猫缶.com</span>
          </Link>
        </div>
        <div className="flex gap-4 mb-4">
          <Link href="/" className="text-black hover:text-gray-700">
            ホーム
          </Link>
          <Link href="/" className="text-black hover:text-gray-700">
            会社情報
          </Link>
          <Link href="/" className="text-black hover:text-gray-700">
            お問い合わせ
          </Link>
        </div>
        <div className="text-black">
          &copy; {new Date().getFullYear()} 猫缶.com. All rights reserved.
        </div>
      </nav>
    </footer>
  );
};

export default Footer;

フッターは特に難しいことは何もしていないのだ。強いていえば猫のかわいさをさりげなくアピールしてるくらいなのだ。

⑤Cardコンポーネントの作成

続いてCardコンポーネントを作成していくのだ。
ここはAPIから商品情報を取得し表示していくのだが、まだAPIが用意できていないのでひとまずダミーデータを表示していくのだ。ダミーデータはこのコンポーネントではなく、後ほど呼び出し元で定義するのだ。

フロントエンドの開発では基本的に「まずダミーデータなどを用意してそれを元に画面を作成」し、画面が完成したら「APIと結合させて本来の情報を取得表示させる」という手順でやっていくのだ。
こうすることで画面作成とAPI結合の責務を分離し、バグが出ても何が原因なのかが特定しやすくなるので開発効率が上がるのだ。

慣れてくると端折って出来てくるが、最初はこの方法でやっていくのをお勧めするのだ!

app/components/Product.tsx
"use client"

import Image from "next/image";
import { ProductType } from "../types/types";
import Link from "next/link";

type ProductProps = {
  product: ProductType;
  isPurchased?: boolean;
};

const Product = ({ product, isPurchased }: ProductProps) => {
  const truncateText = (text: string, maxLength: number) => {
    if (text.length > maxLength) {
      return text.substring(0, maxLength) + "...";
    } else {
      return text;
    }
  };

  const formattedPrice = new Intl.NumberFormat("ja-JP", {
    style: "currency",
    currency: "JPY",
  }).format(product.price);

  return (
    <>
      {/* アニメーションスタイル */}
      <style jsx global>{`
        @keyframes fadeIn {
          from {
            opacity: 0;
            transform: scale(0.9);
          }
          to {
            opacity: 1;
            transform: scale(1);
          }
        }
        .modal {
          animation: fadeIn 0.3s ease-out forwards;
        }
      `}</style>

      <div className="flex flex-col items-center m-4 w-96">
        <Link
          href={`/product/${product.id}`}
          className="cursor-pointer shadow-2xl duration-300 hover:translate-y-1 hover:shadow-none"
        >
          <div className="relative w-96 h-64">
            <Image
              priority
              src={product.thumbnail.url}
              alt={product.title}
              layout="fill"
              objectFit="cover"
              className="rounded-t-md"
            />
          </div>
          <div className="px-4 py-4 bg-slate-100 rounded-b-md h-full">
            <h2 className="text-xl font-semibold">{product.title}</h2>
            {product.tag && (
              <div className="mt-2">
                {product.tag.map((tag, index) => (
                  <span
                    key={index}
                    className="inline-block bg-yellow-400 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2"
                  >
                    {tag}
                  </span>
                ))}
              </div>
            )}
            <p className="mt-2 text-lg text-slate-600">
              {truncateText(product.content, 50)}
            </p>
            <div className="flex justify-between items-center mt-3">
              {isPurchased ? (
                <span className="bg-green-500 text-white px-2 py-1 text-xs rounded">
                  過去に購入済み
                </span>
              ) : (
                <span className="flex-grow"></span>
              )}
              <p className="text-md text-slate-700 text-right">
                {formattedPrice}
              </p>
            </div>
          </div>
        </Link>
      </div>
    </>
  );
};

export default Product;

まず最初の一文に"use client"と記述されているのだ。

"use client"は Server Component と Client Component の境界の宣言なのだ。以下公式ドキュメントより抜粋。

The "use client" directive is a convention to declare a boundary between a Server and Client Component module graph.

ここは少し難しい話になってくるので、あまり長くはお話ししませんが要は

「appディレクトリ内のコンポーネントは、デフォルトですべてReact Server Componentsとなる。
Server ComponentsとClient Componentsにより、Reactはクライアントとサーバーでレンダリングできるようになり、コンポーネントレベルでレンダリング環境を選択することができるようになった」ということなのだ。

めちゃくちゃ噛み砕くと「デフォではサーバーでレンダリングするけど、クライアントレンダリングしたかったら "use client"を宣言してね」と言うことなのだ。

じゃあクライアントでレンダリングしたい時ってどう言う時なの?というと、useStateuseEffectなどのクライアントサイドで動作したり状態を追跡したりするhooksなどを使用する時などが多いと思うのだ。

今回のこのProductコンポーネントについても同様の理由で"use client"を宣言しているのだ。
まだ使用していないが、後ほどuseStateなどを使う予定なので先に記述しておくのだ。

まあ"use client"についてもっと詳しく知りたい方は公式ドキュメントを読んでみるといいのだ。

続いて下記部分についても解説するのだ。

const truncateText = (text: string, maxLength: number) => {
    if (text.length > maxLength) {
      return text.substring(0, maxLength) + "...";
    } else {
      return text;
    }
  };

  const formattedPrice = new Intl.NumberFormat("ja-JP", {
    style: "currency",
    currency: "JPY",
  }).format(product.price);

これはフォーマット関数なのだ。
truncateText関数は商品詳細のテキストが指定の文字数を超える場合は"..."のように省略して表示させるための関数なのだ。
if文でtext.lengthとmaxLengthを比較しているのだ。

formattedPrice関数は商品の価格を円でフォーマットするために使用されるのだ。
「¥3,800」みたいな感じでフォーマットしてくれるのだ。

そして肝心のタイトルや画像、価格などの商品情報の表示については引数としてpropsを受け取っているので、受け取ったpropsを表示しているだけなのだ。

⑥Home画面でヘッダー、フッター、Productコンポーネントを表示

では表示用のコンポーネントの準備が整ったので、ホーム画面に表示させていくのだ。
app rouerになってからは_app.ts_document.tsは不要になり、layout.tsxに置き換わったのだ。

app/layout.tsx

import type { Metadata } from "next";
import { Noto_Sans_JP } from "next/font/google";
import "./globals.css";
import Header from "./components/Header";
import { Suspense } from "react";
import Loading from "./loading";
import Footer from "./components/Footer";

const notoSansJP = Noto_Sans_JP({ weight: "400", subsets: ["latin"] });

export const metadata: Metadata = {
  title: "猫缶.com",
  description: "肉球に届け!",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={notoSansJP.className}>
          <Header />
          <Suspense fallback={<Loading />}>{children}</Suspense>
          <Footer />
      </body>
    </html>
  );
}


ここではページのメタデータ(タイトルや説明など)を定義したり、子コンポーネントに適用するためのLoading表示コンポーネントなどをセットしているのだ。
ちなみにですがフォントもデフォルトから変えてて、「Noto_Sans_JP」を使用しているのだ。

Loadingコンポーネントについては下記のように定義しているのだ。
size と color という2つの変数を定義して、ローディングスピナーを表示しているのだ。

app/loading.tsx
"use client";

import React from "react";
import { ClipLoader } from "react-spinners";

const LoadingSpinner = () => {
  // スピナーのサイズや色をカスタマイズできます
  const size = 50;
  const color = "#123abc";

  return (
    <div className="spinner-container flex items-center justify-center min-h-screen">
      <ClipLoader size={size} color={color} />

      <style jsx>{`
        .spinner-container {
          display: flex;
          justify-content: center;
          align-items: center;
          height: 100vh;
        }
      `}</style>
    </div>
  );
};

export default LoadingSpinner;

そしてapp/page.tsxで先ほど作成したProductコンポーネントを使用して、表示ん一覧を展開していくのだ。
最初は疑似データとしてproductを定義しておくのだ。

app/page.tsx
import Product from "./components/Product";
import Image from "next/image";

type ProductType = {
  id: number;
  title: string;
  price: number;
  content: string;
  thumbnail: { url: string };
  createdAt: string;
  updatedAt: string;
  tag: [];
};

// 疑似データ
const product = [
  {
    id: 1,
    title: "猫缶01",
    thumbnail: "/thumbnails/01.png",
    price: 2980,
    content: "猫缶01の詳細情報です",
    tag: ["ジャンボ缶", "多頭飼", "魚介類", "まとめ買い", "全猫種用", "お徳用"],
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
  {
    id: 2,
    title: "猫缶02",
    thumbnail: "/thumbnails/02.png",
    price: 1980,
    content: "猫缶02の詳細情報です",
    tag: ["魚介類", "まとめ買い", "全猫種用", "お徳用"],
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
  {
    id: 3,
    title: "猫缶03",
    price: 4980,
    thumbnail: "/thumbnails/03.png",
    content: "猫缶03の詳細情報です",
    tag: ["魚介類", "まとめ買い", "全猫種用"],
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
];

export default async function Home() {

  return (
    <>
      <div className="relative w-full h-64 md:h-96">
        <Image
          src="/28480621_l.jpg"
          alt="Header Image"
          layout="fill"
          objectFit="cover"
        />
        <div className="absolute top-1/2 left-0 transform -translate-y-1/2 p-4">
          <h1 className="text-white text-4xl md:text-5xl font-bold">
            - 全ての肉球に届け -
          </h1>
        </div>
      </div>

      <main className="flex flex-wrap justify-center items-center md:mt-16 mt-10">
        <h2 className="text-center w-full font-bold text-3xl mb-2">猫缶一覧</h2>
        {product.map((product: ProductType) => (
          <Product
            key={product.id}
            book={product}
          />
        ))}
      </main>
    </>
  );
}


すると下記のような画面が出来上がるはずなのだ!
これだけでなんかそれっぽくなってきたのだ!

image

⑦NextAuth.jsで認証機能の実装

では商品一覧画面がとりあえず作成できたところで、いよいよ認証機能の実装をやっていくのだ。
先ほども紹介しましたが、今回使用するのはNext.jsとも相性の良いNextAuth.jsを使用していくのだ。

まずはインストールから。

npm install next-auth

npm i @next-auth/prisma-adapter

ますはlib/next-authディレクトリを作成し、その中にoptions.tsを作成するのだ。
ここでGitHubのOAuth認証用の設定をしていくのだ。

app/lib/next-auth/options.ts
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import GitHubProvider from "next-auth/providers/github";

import { NextAuthOptions } from "next-auth";
import prisma from "../prisma";

export const nextAuthOptions: NextAuthOptions = {
  debug: false,
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID ?? "",
      clientSecret: process.env.GITHUB_SECRET ?? "",
    }),
  ],
  adapter: PrismaAdapter(prisma),
  callbacks: {
    session: ({ session, user }) => {
      return {
        ...session,
        user: {
          ...session.user,
          id: user.id,
        },
      };
    },
  },
};

まずはインポートから。
@next-auth/prisma-adapter から PrismaAdapter をインポートするのだ。これは、Prisma ORM を使用してデータベースとやり取りするためのアダプタなのだ。

NextAuthOptionsの設定

debug: デバッグモードを設定。今は特段必要ないので、ここでは falseにしておくのだ。
providers: 使用する認証プロバイダの配列。ここでは GitHub のみを設定。
adapter: PrismaAdapterを使用してデータベース接続を設定するのだ。
callbacks: 認証プロセス中に実行される関数の設定。ここでは session コールバックが定義されており、セッション情報にユーザーIDを追加しているのだ。

ちなみにここのGITHUB_IDGITHUB_SECRETはenvファイルで定義しておくのだ。
どこの値を参照すればいいのかと言うと、

GitHubのsettingsから「Developer settings」→OAuthAppの「New OAuth App」から作成できるのだ。
下記のような感じで設定するのだ。

image.png

作成できたら、「Client ID」と「Client secrets
」が発行されるので、それをそのままenvファイルに設定するだけでいいのだ。

Prismaのセットアップ

続いてPrismaのセットアップをしていくのだ。

npm install prisma --save-dev

// 初期化
npx prisma init

npm i @prisma/client

上記で必要なファイルなどが自動作成されたと思うので、prisma/schema.prismaを開いて下記のように設定し、DBの接続情報をそれぞれ記述するのだ。

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
  provider          = "postgresql"
  url               = env("POSTGRES_PRISMA_URL")
  directUrl         = env("POSTGRES_URL_NON_POOLING")
  shadowDatabaseUrl = env("POSTGRES_URL_NON_POOLING")
}

Prismaのインスタンス化

app/lib/prisma.tsを作成して、下記のように記述していくのだ。
このコードは、Prisma Clientのインスタンスをグローバルに管理するためのものなのだ。
グローバル変数を使用することで、アプリケーション全体で同じインスタンスを再利用できるのだ。

app/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

const globalForPrisma = global as unknown as {
  prisma: PrismaClient | undefined;
};

if (!globalForPrisma.prisma) {
  globalForPrisma.prisma = new PrismaClient();
}
prisma = globalForPrisma.prisma;

export default prisma;

主要な部分の

if (!globalForPrisma.prisma) {
  globalForPrisma.prisma = new PrismaClient();
}
prisma = globalForPrisma.prisma;

のみ解説すると、if (!globalForPrisma.prisma) でグローバルオブジェクトに prisma プロパティが存在しないかチェックし、存在しない場合に new PrismaClient() で新しいインスタンスを作成し、グローバルオブジェクトに割り当てているのだ。

グローバルオブジェクトを使用することで、ホットリロードしても何回も何回もprismaのインスタンスが作成されなくて済む、ということなのだ。

モデルの作成

続いてモデルの作成をしていくのだ。
何を定義すべきなのかは下記ドキュメントに丁寧に記載があるので、prisma/schema.prismaに追記していくのだ。

prisma/schema.prisma

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String        @id @default(cuid())
  name          String?
  email         String?       @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  purchases     Purchase[]    // 購入履歴へのリレーション
  sessions      Session[]
}

model Purchase {
  id        String   @id @default(cuid())
  userId    String   // 購入したユーザーのID
  bookId    String   // 購入した商品のID (MicroCMSのID)
  createdAt DateTime @default(now()) // 購入日時
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade) // Userモデルへのリレーション
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}


上記のように設定したのだ。
Cascadeを使用して外部キー制約も張っているのだ。

例えば User レコードが削除された場合、そのユーザーに関連付けられたすべての Purchase レコードの購入履歴なども自動的に削除されるのだ。

ではここまでできたらマイグレーションをしていくのだ。

npx prisma migrate dev --name init

こんな感じでマイグレーションファイルが作成されればOKなのだ!

image.png

認証用APIの作成

ここまででDBの準備も整ったので、いよいよ本題のログイン機能を実装してくのだ。

app/api/auth/[...nextauth]ディレクトリにroute.tsを作成するのだ。

ちなみにここの...nextauthというのは、公式ドキュメントによると「...APIルートは括弧内に3つのドットを追加することで、全てのパスをキャッチするように拡張できます」とのことなのだ。
つまりここでいうと、authディレクトリの配下全てにAPIを適用させる、と言うことなのだ。

そして中身は下記のように記述するのだ。
これはRoute Handlers機能を使用してNextAuth.jsを初期化しているのだ。

app/api/auth/[...nextauth]/route.ts
import { nextAuthOptions } from "@/app/lib/next-auth/options";
import NextAuth from "next-auth";
const handler = NextAuth(nextAuthOptions);

// https://next-auth.js.org/configuration/initialization#route-handlers-app
export { handler as GET, handler as POST };

これを書かないと正常に動作しないそうなのだ。詳しくは公式ドキュメントを見るのだ。

続いて、app/lib/next-auth/provider.tsxを作成し、NextAuth.js の SessionProvider を利用して、セッション管理のコンテキストを提供するカスタムプロバイダコンポーネントを定義しています。

もっと噛み砕いて説明するとSessionProviderを使用して、アプリケーション内のすべての子コンポーネントから現在のユーザーのログイン情報や認証状態を簡単にアクセスできるようにしています。簡単に言えば、ログインしているユーザーに関する情報をアプリのどの部分からでも参照できるようにしているということですね。

app/lib/next-auth/provider.tsx

"use client";

import { SessionProvider } from "next-auth/react";

import type { FC, PropsWithChildren } from "react";

export const NextAuthProvider: FC<PropsWithChildren> = ({ children }) => {
  return <SessionProvider>{children}</SessionProvider>;
};

app/layout.tsxNextAuthProviderをラップするのを忘れずに。

app/layout.tsx

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={notoSansJP.className}>
        <NextAuthProvider>
          <Header />
          <Suspense fallback={<Loading />}>{children}</Suspense>
          <Footer />
        </NextAuthProvider>
      </body>
    </html>
  );
}

ここまでできたらログイン機能もほぼ完成なのだ。
もう一度app/components/Header.tsxを見て欲しいのだが、ここではログイン時に/api/auth/signinを叩いているのだ。
これはNextAuthが標準で用意してくれているsigninのためのAPIなのだ。これを利用することで簡単にログインすることが出来るのだ。

本当はonClickなどを利用してやりたかったのだが、Server Componentだと不都合が発生してしまうので、この方法にしてみたのだ。

ログアウトの場合は/api/auth/signout?callbackUrl=/でいけるのだ。

ちなみにHeaderコンポーネントに記載の下記は、getServerSessionを使用し、ログインしているユーザーをセッションから取得しているのだ。

app/components/Header.tsx

const session = await getServerSession(nextAuthOptions);
const user = session?.user;

では実際にログインしてみるのだ。
ログインボタンを押して、下記のような画面でGitHub認証が完了できたのだ!

image.png

image.png

⑧MicroCMSで商品登録と商品API取得表示の実装

NextAuthの設定やprismaなどの設定で大変だったと思いますが、続いてMicroCMSで商品を登録できるようにしてみるのだ。

microCMSの登録がまだの方はユーザー登録するのだ。

microCMSはGUI上で操作できるので、画面から色々と設定していきます。

image.png

「API作成」から

image.png

image.png

image.png

こんな感じで簡単に任意のAPIを作成して、商品を登録することが出来たのだ!めちゃくちゃ簡単なのだ!
では登録できたので、次はダミーデータを表示している部分を実際のAPIに置き換える実装をしていくのだ。

microcmsの設定

まずはmicrocms-js-sdkをインストール

npm install microcms-js-sdk

次にenvファイルを設定するのだ。SERVICE_DOMAINは自分が設定したドメインを、API_KEYは「APIキー管理」にあるのでコピペするのだ。

NEXT_PUBLIC_SERVICE_DOMAIN="nekokan"
NEXT_PUBLIC_API_KEY=""

そして下記のように商品一覧取得用の関数を作成するのだ。
nekokanエンドポイントから商品一覧を非同期に取得します。取得時には、クエリパラメータとしてoffseとlimitを指定しています。

app/lib/microcms/client.ts
import { ProductType } from "@/app/types/types";
import { MicroCMSQueries, createClient } from "microcms-js-sdk";

export const client = createClient({
  serviceDomain: process.env.NEXT_PUBLIC_SERVICE_DOMAIN!,
  apiKey: process.env.NEXT_PUBLIC_API_KEY!,
});

export const getAllProducts = async () => {
  const allProducts = await client.getList<ProductType>({
    endpoint: "nekokan",
    queries: {
      offset: 0,
      limit: 10,
    },
  });

  return allProducts;
};

そしてapp/page.tsxでダミーデータを表示していたのを、APIから取得するように修正するのだ。

app/page.tsx
export default async function Home() {
  const session = await getServerSession(nextAuthOptions);
  const user = session?.user as User;
  const { contents } = await getAllProducts();

  let purchasesData = [];
  let purchasedIds: number[] = [];

  if (user) {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/purchases/${user.id}`
    );

    purchasesData = await response.json();
    purchasedIds = purchasesData.map((purchase: Purchase) => purchase.productId);
  }

  return (
    <>
      <div className="relative w-full h-64 md:h-96">
        <Image
          src="/28480621_l.jpg"
          alt="Header Image"
          layout="fill"
          objectFit="cover"
        />
        <div className="absolute top-1/2 left-0 transform -translate-y-1/2 p-4">
          <h1 className="text-white text-4xl md:text-5xl font-bold">
            - 全ての肉球に届け -
          </h1>
        </div>
      </div>

      <main className="flex flex-wrap justify-center items-center md:mt-16 mt-10">
        <h2 className="text-center w-full font-bold text-3xl mb-2">猫缶一覧</h2>
        {contents.map((product: ProductType) => (
          <Product
            key={product.id}
            product={product}
            isPurchased={purchasedIds.includes(product.id)}
          />
        ))}
      </main>
    </>
  );
}

まず下記で現在のユーザーセッションを取得。

const session = await getServerSession(nextAuthOptions);
  const user = session?.user as User;

そして下記で先ほど作成したgetAllProductsを使用して、APIから商品一覧を取得。

 const { contents } = await getAllProducts();

purchasesDatapurchasedIdsを初期化した後に、ユーザーがログインしている場合、そのユーザーの購入履歴をAPIから取得し、購入した本のIDの配列を作成するのだ。
(この購入履歴APIは後ほど解説します)

  let purchasesData = [];
  let purchasedIds: number[] = [];

  if (user) {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/purchases/${user.id}`
    );

    purchasesData = await response.json();
    purchasedIds = purchasesData.map((purchase: Purchase) => purchase.productId);
  }

そして、JSX内のProductコンポーネントでisPurchased={purchasedIds.includes(product.id)}と記述しているのだ。これは先ほどpurchasedIdsで購入した商品IDを取得したので、map関数で展開しているproduct.idと一致するかをincludesメソッドを使用して確認し、一致する場合はtrueを返すと言う訳なのだ。

(includesメソッドは、配列内に特定の要素が存在するかどうかをチェックし、その結果を真偽値(trueまたはfalse)で返すJavaScriptのメソッドです。)

これは何のためにやっているのかと言うと、購入済みのものはProductコンポーネントに「過去に購入済み」のラベルを表示し、過去にその商品を買ったことがあるかどうかをユーザーにわかりやすく表示するために設定しているのだ。

    <Product
        key={product.id}
        product={product}
        isPurchased={purchasedIds.includes(product.id)}
    />

こうすることで下記のようにいい感じに商品一覧をAPIから表示することが出来たのだ!

image.png

⑨商品詳細ページの実装

では一覧が表示できたので、ついでに詳細ページも実装していくのだ。

app/book/[id]/page.tsx

"use client";

const modalStyle = {
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  position: "fixed" as "fixed",
  top: "0",
  left: "0",
  right: "0",
  bottom: "0",
  backgroundColor: "rgba(0, 0, 0, 0.5)",
  zIndex: "1000",
};

const modalContentStyle = {
  backgroundColor: "white",
  padding: "20px",
  borderRadius: "10px",
  boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
  zIndex: "1001",
};

const DetailProduct = ({ params }: { params: { id: string } }) => {
  const [product, setProduct] = useState<ProductType | null>(null);
  const [loading, setLoading] = useState(true);
  const [showModal, setShowModal] = useState(false);
  const router = useRouter();

  const { data: session } = useSession();
  const user: any = session?.user;

  useEffect(() => {
    const fetchProduct = async () => {
      try {
        const fetchedProduct = await getDetailProduct(params.id);
        setProduct(fetchedProduct);
        setLoading(false);
      } catch (error) {
        console.error("Error fetching product details:", error);
        setLoading(false);
      }
    };

    fetchProduct();
  }, [params.id]);

  if (loading) {
    return <Loading />;
  }

  if (!product) {
    return <div>Product not found</div>;
  }

  const handlePurchaseConfirm = () => {
    if (!user) {
      setShowModal(false);
      router.push("/login");
    } else {
      //Stripe購入画面へ。
    }
  };

  const handleOpen = () => {
    setShowModal(true);
  };

  const handleCancel = () => {
    setShowModal(false);
  };

  const handleBack = () => {
    router.back();
  };

  const formattedPrice = new Intl.NumberFormat("ja-JP", {
    style: "currency",
    currency: "JPY",
  }).format(product.price);

  const formatContent = (content: string) => {
    return { __html: content.replace(/\n/g, "<br>") };
  };

  return (
    <div className="container mx-auto p-4 mt-8 mb-8">
      <div className="bg-white shadow-lg rounded-lg overflow-hidden">
        <Image
          className="w-full h-80 object-cover object-center"
          src={product.thumbnail.url}
          alt={product.title}
          width={700}
          height={700}
        />
        <div className="p-4">
          <div className="flex justify-between items-center mt-2">
            <span className="text-sm text-gray-500">
              公開日: {new Date(product.createdAt).toLocaleString()}
            </span>
            <span className="text-sm text-gray-500">
              最終更新: {new Date(product.updatedAt).toLocaleString()}
            </span>
          </div>
          <h2 className="text-3xl font-bold mt-5">{product.title}</h2>
          <div
            className="text-gray-700 mt-10 mb-20"
            dangerouslySetInnerHTML={formatContent(product.content)}
          />

          <div className="flex justify-center items-center space-x-2">
            <p className="text-3xl text-red-600">{formattedPrice}</p>
            <p className="text-gray-400">+送料500円</p>
          </div>

          <div className="flex justify-center items-center mt-14 mb-14">
            <button
              onClick={handleBack}
              className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mr-4 text-lg"
            >
              戻る
            </button>
            <button
              onClick={handleOpen}
              className="bg-yellow-400 hover:bg-yellow-500 text-white font-bold py-2 px-4 rounded text-lg"
            >
              購入する
            </button>
          </div>

          {showModal && (
            <div style={modalStyle}>
              <div style={modalContentStyle}>
                <h3 className="text-xl mb-4">この猫缶を購入しますか</h3>
                <button
                  onClick={handleCancel}
                  className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mr-4"
                >
                  キャンセル
                </button>
                <button
                  onClick={handlePurchaseConfirm}
                  className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-4 rounded "
                >
                  購入する
                </button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default DetailProduct;

Client Componentsにしたいので"use client"を宣言するのだ。

そして下記で商品詳細情報の取得をしているのだ。
その際にsetLoadingでLoading表示の制御も行っているのだ。

useEffect(() => {
    const fetchProduct = async () => {
      try {
        const fetchedProduct = await getDetailProduct(params.id);
        setProduct(fetchedProduct);
        setLoading(false);
      } catch (error) {
        console.error("Error fetching product details:", error);
        setLoading(false);
      }
    };

あとは見たまんまでモーダルのクリック時の動作などを設定していたり、商品が存在しない場合は適切なメッセージを表示したりしているのだ。
そして下記で「購入する」ボタンを押した時の動作を定義していて、未ログインであればログインページ遷移、ログイン済みであれば決済ページへ遷移させるのだ。
この決済機能は後ほど実装していくのだ。

const handlePurchaseConfirm = () => {
    if (!user) {
      setShowModal(false);
      router.push("/login");
    } else {
      //Stripe購入画面へ。
    }
  };

画面の方を確認してみると、こんな感じで詳細情報を表示できていると思うのだ!

image.png

➓stripeで決済機能の実装

と言うことで早速決済機能を実装していくのだ。
登録がまだの方は下記で登録しておくのだ。

まずはstripeをインストール

npm i stripe

登録を進めていくとこんな感じの画面が表示されるので

image.png

下記ページでシークレットキーをコピーしてenvファイルに貼り付けるのだ。

image.png

上記でenvファイルの設定ができたらコードの方も実装していくのだ。

app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";

// 初期化
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;

export async function POST(request: Request, response: Response) {
  const { title, price, productId, userId } = await request.json();

  try {
    // チェックアウトセッションの作成
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ["card"],
      metadata: {
        productId: productId,
      },
      client_reference_id: userId,
      line_items: [
        {
          price_data: {
            currency: "jpy",
            product_data: {
              name: title,
            },
            unit_amount: price,
          },
          quantity: 1,
        },
      ],
      mode: "payment",
      success_url: `${baseUrl}/product/checkout-success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${baseUrl}`,
    });
    return NextResponse.json({
      checkout_url: session.url,
    });
  } catch (err: any) {
    return NextResponse.json({ message: err.message });
  }
}

try catch文でチェックアウトセッションの作成を行っていくのだ。
stripeで用意してくれているstripe.checkout.sessions.create関数を呼び出して
payment_method_types: ["card"]で支払い方法をカードに設定。
currency: "jpy"で日本円を指定。
name: titleで決済ページに表示させる情報を指定するのだ。
success_urlは決済完了後のリダイレクトページのURLを指定するのだ。
逆にキャンセル時の遷移先はcancel_urlで設定できるのだ。

const session = await stripe.checkout.sessions.create({
      payment_method_types: ["card"],
      metadata: {
        productId: productId,
      },
      client_reference_id: userId,
      line_items: [
        {
          price_data: {
            currency: "jpy",
            product_data: {
              name: title,
            },
            unit_amount: price,
          },
          quantity: 1,
        },
      ],
      mode: "payment",
      success_url: `${baseUrl}/book/checkout-success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${baseUrl}`,
    });

これで決済用のAPI実装は完了なのだ。
ではこれを先ほどの商品詳細ページの「購入する」を押下時に決済ページへ遷移するように実装していくのだ。

startCheckout/checkout エンドポイントにPOSTリクエストを送信するのだ。
その際にproductId、title、price、userIdを含めてリクエストするのだ。そしてそれをJSON形式で受け取るのだ。

正常なレスポンスがあった場合、レスポンスからStripeのチェックアウトURL(checkout_url)を取得。

StripeのセッションID(session_id)を sessionStorage に保存し、取得したチェックアウトURLにユーザーをリダイレクトさせるのだ。

app/book/[id]/page.tsx
const DetailProduct = ({ params }: { params: { id: string } }) => {

  useEffect(() => {
    const fetchProduct = async () => {
      try {
        const fetchedProduct = await getDetailProduct(params.id);
        setProduct(fetchedProduct);
        setLoading(false);
      } catch (error) {
        console.error("Error fetching product details:", error);
        setLoading(false);
      }
    };

    fetchProduct();
  }, [params.id]);



  //stripe checkout
  const startCheckout = async (productId: number) => {
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/checkout`,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            productId,
            title: product.title,
            price: product.price,
            userId: user?.id,
          }),
        }
      );

      const responseData = await response.json();

      if (responseData && responseData.checkout_url) {
        sessionStorage.setItem("stripeSessionId", responseData.session_id);

        //チェックアウト後のURL遷移先
        router.push(responseData.checkout_url);
      } else {
        console.error("Invalid response data:", responseData);
      }
    } catch (err) {
      console.error("Error in startCheckout:", err);
    }
  };

  const handlePurchaseConfirm = () => {
    if (!user) {
      setShowModal(false);
      router.push("/login");
    } else {
      //Stripe購入画面へ。
      startCheckout(product.id);
    }
  };

  const handleOpen = () => {
    setShowModal(true);
  };



そして実際に「購入する」ボタンを押すと、下記のようにstripeの決済ページに遷移することが出来るのだ。

image.png

そして決済を完了させると自動的に遷移して、http://localhost:3000/book/checkout-success?session_id=xxxxx のようなURLにリダイレクトされるはずなのだ!

実際に stripe の画面から確認してみると、正常に決済が完了していることが確認できるのだ。

image.png

11.決済後のページ実装

ここまで来たら後少しなのだ!
購入履歴を保存する処理を追加していくのだ。

下記コードは、チェックアウトセッションから購入情報を取得し、Prismaを通じてデータベースに新しい購入履歴を保存する処理を行っているのだ。

まずsessionIdを取得し、そのsessionIdでチェックアウトセッションの詳細を取得。これによってuserIdproductIdなどの詳細情報がconst sessionに格納されるのだ。

取得したセッション情報(ユーザーID、商品IDなど)を使って、新しい購入履歴をデータベースに保存するのだ。

これによって購入履歴を保存する処理が実装できたのだ。

app/api/checkout/success/route.ts

import prisma from "@/app/lib/prisma";
import { NextResponse } from "next/server";
import Stripe from "stripe";

// 初期化
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request, response: Response) {
  const { sessionId } = await request.json();

  try {
    const session = await stripe.checkout.sessions.retrieve(sessionId);

    // 新しい購入履歴を常に作成する
    const purchase = await prisma.purchase.create({
      data: {
        userId: session.client_reference_id!,
        productId: session.metadata?.productId!,
      },
    });
    return NextResponse.json({ purchase });
  } catch (err: any) {
    return NextResponse.json({ message: err.message });
  }
}


りなみに上記POST送信を実際に行なっているのはapp/book/checkout-success/page.tsxの箇所なので、そこも気になる方はGitHubの方を確認して欲しいのだ!今回は長くなりすぎるので端折っていくのだ。

では実際に購入履歴が保存されるかどうかを見ていくのだ。
下記コマンドを実行して、DBの中のPurchaseテーブルを見てみるのだ。

npx prisma studio

下記画像のようにしっかり保存されていることが確認できたのだ!
これで過去に購入した商品が分かるようになったのだ。

image.png

12.マイページの実装

それでは最後にマイページを実装していくのだ。

app/profile/page.tsx

import React from "react";
import Image from "next/image";
import { getDetailProduct } from "../lib/microcms/client";
import { ProductType, Purchase } from "../types/types";
import { getServerSession } from "next-auth";
import { nextAuthOptions } from "../lib/next-auth/options";
import Product from "../components/Product";

export default async function ProfilePage() {
  const session = await getServerSession(nextAuthOptions);
  const user: any = session?.user;

  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/purchases/${user.id}`
  );
  const data = await response.json();

  // 各購入履歴に対してmicroCMSから詳細情報を取得
  const detailProducts = await Promise.all(
   data.map(async (purchase: Purchase) => {
     const res = await getDetailProduct(purchase.productId);
     return res;
   })
  );


  return (
    <div className="container mx-auto p-4">
      <h1 className="text-xl font-bold mb-4">プロフィール</h1>

      <div className="bg-white shadow-md rounded p-4">
        <div className="flex items-center">
          <Image
            priority
            src={user?.image || "/default_icon.png"}
            alt="user profile_icon"
            width={60}
            height={60}
            className="rounded-t-md"
          />
          <h2 className="text-lg ml-4 font-semibold">お名前{user?.name}</h2>
        </div>
      </div>

      <span className="font-medium text-lg mb-4 mt-4 block">
        過去に購入した猫缶
      </span>
      <div className="flex items-center gap-6">
        {detailProducts.map((detailProduct: ProductType) => (
          <Product key={detailProduct.id} product={detailProduct} />
        ))}
      </div>
    </div>
  );
}

基本的には過去に購入した商品を表示と、ユーザー情報を表示しているだけなのだ。

特にdetailProductsの部分を解説すると、Promise.allで複数の非同期処理を実行。map関数で購入履歴(purchase)をループ処理。getDetailProduct関数を呼び出して、purchase.productIdを引数に与え、その商品の詳細情報を非同期に取得しているのだ。

// 各購入履歴に対してmicroCMSから詳細情報を取得
  const detailProducts = await Promise.all(
   data.map(async (purchase: Purchase) => {
     const res = await getDetailProduct(purchase.productId);
     return res;
   })
  );

13.デプロイ

これでようやく全ページ実装完了することが出来たのだ!
では完成したところでVercelにデプロイしていくのだ。

初期デプロイはVercel Postgresのセットアップの際に既に行なっているので、後はGitHubにプッシュするだけなのだ!

その前にpackage.jsonのbuildコマンドを以下

"build": "prisma generate && next build ",

のように変えておくのだ。これをしないとビルドの際にコケてしまうので、これで成功するはずなのだ!

実際にプッシュするだけで自動デプロイされるはずなので、Vercelを確認してみるのだ。

下記のような表示になっていたら無事デプロイ成功なのだ!

image.png

本番の環境変数の設定

ではデプロイ自体は成功しましたが、まだ本番の環境変数を設定できていないので動かないと思うのだ。
なので環境変数を設定するのだ。

VercelのSettingsの中の「Environment Variables」で環境変数を設定できるのだ。
追加する環境変数はenvファイルに記載してるものをそのまま追加していくだけなのだ!

image.png

と言うことで無事本番反映まで完了できたのだ!

image.png

6.storybookについて

さて、今回は個人開発ということもあって導入はしなかったのですがstorybookについても触れてみたいと思うのだ。
比較的規模が大きめの案件とかになってくると、積極的に導入されているケースをよく見るのだ。

じゃあそのstorybookって何なの?というと、簡単に言うと「UIカタログ」なのだ。それぞれのUIコンポーネントをブラウザで手軽にチェックすることができるのだ。React以外にもVueやAngular、Svelteなどにも対応しています。オープンソースで無料のサービスなのだ。

こんな感じでstory単位でUIを確認することが出来るのだ。

image.png

storybookを導入するメリット

storybookを導入するメリットとしては以下の通り

  • 手軽にUIのテストができる
  • サーバー側の準備ができていなくても先にUIを作ることができる
  • 非開発者でも手軽に手元で実際の画面や動作を確認することが出来る

手軽にUIのテストができる

実際の画面上からUIのテストをする際、普通はそのテストケースに合ったテストデータを作成して正常系のテストや異常系のテストを行うと思うのだ。
しかしstorybookがあれば、わざわざテスト用のデータを作成せずとも画面上から値をいじることが可能なので、臨機応変に素早くチャックすることが可能なのだ。

さらに、表示するテキストが長くなったときにレイアウトが崩れが出てしまう、なんてことはよくあるのだ。
他にも、値がnullの場合にどう表示されるのか?、などのパターンを事前にチェックすることが可能なのだ。

結果、UIの品質を高水準で保つことが可能になるのだ。

サーバー側の準備ができていなくても先にUIを作ることができる

フロントエンド開発では「データが無いとUI見れないのでサーバー側の開発を待たなくてはいけない」みたいなことがあると思うのだ。
Storybookでは事前に用意した簡素なデモデータを用いてUIチェックを行うことができるので、サーバー側の開発を待たずにフロント開発ができるのだ。開発サイクルを速くすることができるので、結果的にリファクタなどにも時間を割けれるようになりとても嬉しいのだ。

非開発者でも手軽に手元で実際の画面や動作を確認することが出来る

ここも意外と重要で、PRに上がってきたコードを確認する時、レビュアー自身も実際の画面上の動きや表示などを確認したいのだ。
その時、わざわざコードをpullしてローカル環境を立ち上げて…と言うのをやってると時間や手間がかかってしまいます。大体レビュアーになるような方はいくつものタスクを持っているので忙しいのだ。
そんな時に、Storybookの環境が整っていればすぐに手元で確認できて、しかもnullなどの場合の表示確認などもできるのだ。

そんな感じでstorybookを導入するには導入コストやメンテナンスなどもしていかなければならないが、その分非常に便利なツールなので是非気になる方は勉強してみて下さい。

7.Global State 管理について

Global状態管理ライブラリは今回のこのアプリでは規模が小さく、親から子への受け渡しのみで済んだため特段利用しなかったのだ。
しかし、開発規模が大きくなってくるとそうもいかなくなるのだ。
そんな時にpropsのみで状態を親から子へpropsを介して伝えていく方法はバケツリレー方式と呼ばれているのだ。これでは非常に効率が悪く、コードから追うのも大変になってくるのだ!

そこで状態管理ライブラリの一つであるRecoilがおすすめなのだ!

補足
どうやらRecoilは2022年10月にRecoilの主要メンテナの@drarmstr氏がMeta社の一斉レイオフで解雇されたらしく、Recoilは開発が止まっているようです。(2023年4月に同氏が小さなアップデートをリリースしていますが、code-frequencyを見る限りほとんど止まっている)

Jotai

ということでRecoilを推そうと思ったが、将来性が厳しそうなので次点でおすすめのJotaiをお勧めするのだ!

サイトの一行目から「Recoilにインスパイアされた」と言ってるとおり、Recoilの良いところを受け継ぎ、不便なところを無くしたような状態管理ライブラリなのだ。

ちなみにですが、パッケージ名は
「日本語でStateって何て言うん?????」
「あー、なんかジョウタイって言うらしいぜ!」
「何それ!じゃあ名前それにしようぜ!」

みたいなノリ(脚色有)で日本語の「状態」から名付けられたようなのだ。
公式ドキュメントからも分かるように、遊び心満載の作者なのだ。

Jotaiとは

  • ボトムアップアプローチ型の状態管理ライブラリ(単一のStateではなく複数のStateを自由にグローバル定義・利用する。1つのStateをAtomと呼称する)
  • ミニマルなAPI
  • AtomにユニークなKeyを設定する必要は無し(Recoilは必要)
  • Providerレスモードで利用可能
  • TypeScriptで開発されている

また、JotaiがRecoilよりも使いやすい点としてバンドルサイズが小さいところや、atomしかない点、keyが要らない点などなど後発というだけあってRecoilの良さを踏襲しつつ、さらに使いやすくなっている印象なのだ。

RecoilからJotaiに移行している企業も多いようなので、試しに使ってみるのもいいのではないだろうか。

8.フロントにテストは必要か

はい。次はテストどうするのか問題なのだ。
そもそもフロントエンドにおいてテストを書く意味はあるのか、というところから意見が分かれがちなのだ。ベストプラクティスも定まっていない印象を受けるのだ。

とはいえ、下記記事にもありますように、
テストを書くにあたって、テストを書くメリットについてチームが同じ理解を持つことが大切で、もしテストを書く ROI (投資対効果/投資収益率)が低いと感じる場合は、テストを書かないのも一つのテスト戦略とも言えるのだ。

この2つのコストとメリットを天秤にかけ、コストの方が上回るようであれば上記でも記したように「テストを書かない」という戦略も有用と言えるのだ。
あくまで論理的に考えるべきであって、めんどくさいから書かないというのは論外なのだ。

そもそものテスト導入メリットとしては

  • 開発中に早い段階でエラーを発見しやすくすることで、開発効率と開発体験を上げる
  • 機能が増えた時に、既存の機能を壊さない可能性を上げる
  • 画面に不具合が発生し、ユーザーの期待通りの挙動にならないことを防ぐ
  • 自動テストにすることで、安全で高速にリリースできる

が挙げられるのだ。

よくあるのはこの前のリリースまではAの機能が正常に動作していたが、軽微なバグ改修が影響して動かなくなっていた、なんてことはよくあるのだ。いわゆるデグレというやつなのだ。
こう言う場面に陥らない為にも、最低限の機能テストくらいが必要であるかもしれないのだ。

しかしここに関しては先ほども示したROI (投資対効果/投資収益率)を考えて、チームが同じ理解を持つことが大切なのだ。
とは言え、いつでもテストは書けるように勉強しておくことも大切なのだ。

9.おわりに

ここまでクソ長い記事を読んでくれてありがとうなのだ!
ずんだもんの影響を受けて、今回は文体がずんだもん風になってしまってごめんなのだ!
途中から普通に書きたい…と思ったのだが、変えるに変えられずここまで来てしまったのだ。。読みにくかったら申し訳ないのだ・・・。

昨今のNext.jsの進化が止まらないので置いていかれないように最新情報をキャッチアップしつつ、積極的に活用していきたいと思うのだ!

参考

360
349
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
360
349

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?