9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】最新のNext14で駆け出しエンジニア支援サイト作ってみた

Last updated at Posted at 2024-03-17

はじめに

皆さんこんにちは、mamiと申します。
今回は個人開発として駆け出しエンジニア支援サイトを作ってみたので、その技術スタックやコード解説なんかをしていこうかなと思います!
バックエンドは用意せずに全てNext.jsとライブラリで完結しているので、開発方法を見るだけでも面白いのではないのかなと思います。
また、最新のNext14のApp routerで開発を行っているので、最新のNext.jsってどんな事してるの?という方にも刺さるのではないでしょうか。

どんなアプリを作ったのか?

早速ですが、作ったアプリはデプロイしているので気になる人は下記を実際に見てみてください。
完結に説明すると、駆け出しエンジニアのための情報集約サイトになります!

言語別のおすすめ記事や、ロードマップ、スクールについてなどをまとめております。
会員登録不要で利用することができ、会員登録するとお気に入り記事を登録することができたり、新着記事などが投稿されるとメール通知を行ったりなどの機能が利用できます。

バタバタしていて中身のコンテンツはまだ空っぽ状態です!
今回はあくまで側のみを開発したので、サービスとしての運用はこれからやっていく予定です。なので設計や技術面にフォーカスした記事になります!

image.png

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

技術構成

フロント 技術
フロントエンド TypeScript, Next14(app router), TailwindCSS
認証 NextAuth.js
データベース PostgreSQL
ORM Prisma
インフラ Vercel

DB設計

User テーブル

フィールド データ型 制約 説明
id String プライマリーキー、デフォルト値(cuid()) ユーザーID
name String オプショナル ユーザー名
email String オプショナル、ユニーク ユーザーのEメールアドレス
emailVerified DateTime オプショナル Eメールの認証日時
image String オプショナル ユーザーの画像URL
accounts Account[] - ユーザーのアカウントリスト
sessions Session[] - ユーザーのセッションリスト
favorites Favorite[] - ユーザーのお気に入りリスト

Account テーブル

フィールド データ型 制約 説明
id String プライマリーキー、デフォルト値(cuid()) アカウントID
userId String 外部キー(User.id)、onDelete: Cascade ユーザーID
type String - アカウントの種類
provider String - アカウント提供者
providerAccountId String - 提供者のアカウントID
refresh_token String オプショナル リフレッシュトークン
access_token String オプショナル アクセストークン
expires_at Int オプショナル トークンの有効期限
token_type String オプショナル トークンの種類
scope String オプショナル トークンのスコープ
id_token String オプショナル IDトークン
session_state String オプショナル セッションの状態
user User リレーション 所有ユーザー

Session テーブル

フィールド データ型 制約 説明
id String プライマリーキー、デフォルト値(cuid()) セッションID
sessionToken String ユニーク セッショントークン
userId String 外部キー(User.id)、onDelete: Cascade ユーザーID
expires DateTime - 有効期限
user User リレーション 所有ユーザー

VerificationToken テーブル

フィールド データ型 制約 説明
identifier String - 識別子
token String ユニーク トークン
expires DateTime - 有効期限

Favorite テーブル

フィールド データ型 制約 説明
userId String 外部キー(User.id)、onDelete: Cascade ユーザーID
articleId String - 記事ID(microCMSで管理)
favorited_at DateTime デフォルト値(now()) お気に入り登録日時
user User リレーション 所有ユーザー

実装

では、設計周りの準備が整ったところでいよいよ実装手順も解説していきます!

1.環境構築

①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 のセットアップは完了です!

2.認証機能の実装

①GitHub認証の実装

今回使用するのはNext.jsとも相性の良いNextAuth.jsを使用していきます。
ちなみに認証はOAuth認証のみ実装していきます。

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

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ファイルに設定するだけでOKです。

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に追記していきます。

今回はUserテーブルなどに加えて、後に使うFavoriteテーブルも作成しておきます。
ちなみに記事の情報はmicroCMSで管理していくので、ここでは定義しません。

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[]
  sessions      Session[]
  favorites     Favorite[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model Favorite {
  userId       String
  articleId    String   // microCMSで管理される記事ID
  favorited_at DateTime @default(now())

  user      User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@id([userId, articleId])
}


と言うことで上記のように設定しました。
ここまでできたらマイグレーションをしていきます。

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 を利用して、セッション管理のコンテキストを提供するカスタムプロバイダコンポーネントを定義しています。

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>
  );
}

ここまでできたらGitHubでのログイン機能もほぼ完成です。
画面は後で用意するとしてもう一つのGoogle認証の方もついでに実装しておきましょう。

②Google認証の実装

先ほどnext-auth/options.tsでGitHubの設定をしたところにGoogle認証の設定も追加していきます。

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

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 ?? "",
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID ?? "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
    }),
  ],
  adapter: PrismaAdapter(prisma),
  callbacks: {
    session: ({ session, user }) => {
      return {
        ...session,
        user: {
          ...session.user,
          id: user.id,
        },
      };
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
};

ログインページはこんな感じで作っていきます。

app/login/page.tsx
"use client";
import { getProviders, signIn, ClientSafeProvider } from "next-auth/react";
import { Provider } from "react";

async function Login() {
  const providers = await getProviders().then(
    (res: Record<string, ClientSafeProvider> | null) => {
      return res || {};
    }
  );

  return (
    <div className="flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            アカウントにログイン
          </h2>
        </div>
        <div className="mt-8 space-y-6">
          {providers &&
            Object.values(providers).map((provider) => {
              return (
                <div key={provider.id} className="text-center">
                  <button
                    onClick={() => signIn(provider.id, { callbackUrl: "/" })}
                    className="bg-gray-900 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded flex items-center justify-center w-full"
                  >
                    <svg
                      role="img"
                      viewBox="0 0 24 24"
                      xmlns="http://www.w3.org/2000/svg"
                      className="w-6 h-6 mr-2"
                      fill="currentColor"
                    >
                      <title>GitHub icon</title>
                    </svg>
                    <span>
                      {provider.name === "GitHub"
                        ? "Githubでログイン"
                        : "Googleでログイン"}
                    </span>
                  </button>
                </div>
              );
            })}
        </div>
      </div>
    </div>
  );
}

export default Login;

OAuthの同意画面の作成

利用者に向けて開示する OAuth の同意画面を作成していきます。
GoogleCloudの中の「認証情報」から設定することができます。

image.png

image.png

image.png

image.png

image.png

image.png

最後の画面までいけたらOKです!

認証情報の取得

認証情報(クライアントIDとクライアントシークレット)を取得します。
envファイルに書くやつですね。

image.png

image.png

envファイルにそれぞれ貼り付けてください。

GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

上手くいかなかった方は詳しくは下記記事を参照してみてください。

では実際にログインしてみましょう。
ログインボタンを押して、下記のような画面でGitHub認証が出来るようになりました!

image.png

image.png

Google認証はこんな感じ

image.png

ちなみにログイン画面はこんな感じです!

image.png

3.フロントエンド開発

では認証機能が実装出来たところで、早速画面の方も作っていきます!

①ヘッダー・フッターの作成

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

app/components/Layout/Header.tsx
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { getServerSession } from "next-auth";
import { nextAuthOptions } from "../../lib/next-auth/options";
import { DefaultButton } from "../button/DefaultButton";
import { IoLogOutOutline, IoLogInOutline } from "react-icons/io5";

type User = {
  name?: string | null;
  email?: string | null;
  image?: string | null;
};

type UserProfileOrLoginProps = {
  user?: User | null;
};

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

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

const UserProfileOrLogin: React.FC<UserProfileOrLoginProps> = ({ user }) => {
  return (
    <NavLink href={user ? "/api/auth/signout" : "/login"}>
      <div className="flex flex-col items-center gap-2 text-white">
        {user ? (
          <IoLogOutOutline className="text-2xl" />
        ) : (
          <IoLogInOutline className="text-2xl" />
        )}
        <span className="text-xs">{user ? "ログアウト" : "ログイン"}</span>
      </div>
    </NavLink>
  );
};

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

  return (
    <header className="bg-gradient-to-b from-indigo-700 to-purple-400 shadow-lg">
      <nav className="flex items-center justify-between p-2">
        <Link href={"/"} className="flex items-center text-white">
          <Image
            src="/createEndineer-logo-main.png"
            alt="創造エンジニアロゴ"
            width={200}
            height={200}
          />
        </Link>
        <div className="flex items-center gap-1">
          <UserProfileOrLogin user={user} />
          <Link
            href="https://forms.gle/sqA815LLnxxoidEd6"
            target="_blank"
            className="mr-3 hidden sm:block"
          >
            <DefaultButton text="お問い合わせ" rounded={true} />
          </Link>
          {user && (
            <Link
              href={`/mypage`}
              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>
          )}
        </div>
      </nav>
    </header>
  );
};

export default Header;

デザイン部分は書いているまんまなので特に解説はしませんが、以下部分ではNextAuth.jsでセッションの取得を行っております。
先ほど設定したnextAuthOptionsに基づいて現在のユーザーのセッションオブジェクトを非同期に取得します。この処理により、サーバーサイドでユーザーの認証状態を確認することができます。

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

そして取得したユーザー情報を見てログイン状態を判断したり、アイコン表示に利用したりしています。

続いてフッターコンポーネントの作成です。
こちらはデザインの表示のみなので特に解説はないです。

app/components/Layout/Footer.tsx
import React from "react";
import Link from "next/link";

const Footer = () => {
  return (
    <footer className="bg-gradient-to-b from-indigo-700 to-purple-400 shadow-lg">
      <div className="container flex flex-col pt-5 pb-5 text-center text-white">
        <Link href="/contact" className="underline font-thin mb-10 mt-3">
          お問い合わせ
        </Link>
        <span className="font-light">
          ©2024 創造エンジニア All rights reserved
        </span>
      </div>
    </footer>
  );
};

export default Footer;

と言うことで画面はこんな感じになりました。
ちなみに今回は「シンプルで直感的に使いやすいデザイン」を意識して作成しています。

image.png

②サイドバーの作成

続いてサイドバーの作成です。
useStateuseEffectなどを使って状態を管理していきたいので、このコンポーネントはClientComponentsで書いていきます。

app/components/SideBar.tsx
"use client";

import React, { ReactNode, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { FaCrown } from "react-icons/fa6";
import { FaStar } from "react-icons/fa";
import { TbLanguageHiragana } from "react-icons/tb";
import { FaRoad } from "react-icons/fa";
import { IconType } from "react-icons";
import { FaSchool } from "react-icons/fa";
import { FaBook } from "react-icons/fa";
import { FaHandPaper } from "react-icons/fa";
import { FaPencilAlt } from "react-icons/fa";
import { FaYoutube } from "react-icons/fa";
import { MdWork } from "react-icons/md";
import { HiOfficeBuilding } from "react-icons/hi";
import { RiComputerLine } from "react-icons/ri";

type SidebarLinkProps = {
  href: string;
  icon: IconType;
  children: ReactNode;
  selectedOption: string;
};

const SidebarLink: React.FC<SidebarLinkProps> = ({
  href,
  icon: Icon,
  children,
  selectedOption,
}) => (
  <Link href={href}>
    <li
      className={`text-slate-800 px-7 py-2 flex items-center ${
        selectedOption === href ? "bg-yellow-400" : ""
      }`}
    >
      <Icon className="mr-1" />
      {children}
    </li>
  </Link>
);

export default function SideBar() {
  const router = useRouter();
  const pathname = usePathname();
  const [selectedOption, setSelectedOption] = useState("");

  useEffect(() => {
    setSelectedOption(pathname);
  }, [pathname]);

  return (
    <div className="bg-white w-56 h-screen shadow-lg pt-5">
      <span className="px-4 py-2 font-bold text-slate-800">人気</span>
      <ul>
        <SidebarLink
          href="/ranking"
          icon={FaCrown}
          selectedOption={selectedOption}
        >
          閲覧ランキング
        </SidebarLink>
        <SidebarLink
          href="/column"
          icon={FaStar}
          selectedOption={selectedOption}
        >
          コラム
        </SidebarLink>
      </ul>
      <div className="border-t border-gray-300 mx-4 my-2"></div>

      <span className="px-4 py-2 font-bold text-slate-800">勉強</span>
      <ul>
        <SidebarLink
          href="/a"
          icon={TbLanguageHiragana}
          selectedOption={selectedOption}
        >
          言語別おすすめ記事
        </SidebarLink>
        <SidebarLink href="/a" icon={FaRoad} selectedOption={selectedOption}>
          必見ロードマップ
        </SidebarLink>
        <SidebarLink href="/a" icon={FaSchool} selectedOption={selectedOption}>
          スクールおすすめランキング
        </SidebarLink>
        <SidebarLink href="/a" icon={FaBook} selectedOption={selectedOption}>
          おすすめ書籍
        </SidebarLink>
        <SidebarLink
          href="/a"
          icon={FaHandPaper}
          selectedOption={selectedOption}
        >
          おすすめ質問サイト
        </SidebarLink>
        <SidebarLink
          href="/a"
          icon={FaPencilAlt}
          selectedOption={selectedOption}
        >
          独学おすすめサイト
        </SidebarLink>
        <SidebarLink href="/a" icon={FaYoutube} selectedOption={selectedOption}>
          おすすめYouTube
        </SidebarLink>
      </ul>
      <div className="border-t border-gray-300 mx-4 my-2"></div>

      <span className="px-4 py-2 font-bold text-slate-800">転職</span>
      <ul>
        <SidebarLink href="/a" icon={MdWork} selectedOption={selectedOption}>
          求人サイト
        </SidebarLink>
        <SidebarLink
          href="/a"
          icon={HiOfficeBuilding}
          selectedOption={selectedOption}
        >
          転職対策
        </SidebarLink>
        <SidebarLink
          href="/a"
          icon={RiComputerLine}
          selectedOption={selectedOption}
        >
          ポートフォリオ
        </SidebarLink>
      </ul>
    </div>
  );
}

こんな感じになりました。
カテゴリーをクリックすると、そのカテゴリーがマークアップされて、今どのカテゴリーを閲覧しているのかが視覚的にわかりやすくなります。

image.png

③バナー画像(スライダー)

スライダーは安定のSwiperを使用します。

導入手順や細かい仕様については公式ドキュメントをご参照ください。
下記にコメント追記してどう言うプロパティなのかはザッと記しておきます。

対応するURL部分については記事のURLが確定したら書き換えていきます。
バナーのURLはそうそう変わることはないので、この仕様にしました。

app/components/home/Banners.tsx
"use client";

import Image from "next/image";

import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";

const images = [
  "/banner_images/1.png",
  "/banner_images/2.png",
  "/banner_images/3.png",
  "/banner_images/4.png",
  "/banner_images/5.png",
  "/banner_images/6.png",
  "/banner_images/7.png",
  "/banner_images/8.png",
];

// 対応するURLを設定
const urls = ["/", "/", "/", "/", "/", "/", "/", "/"];

export default function Banners() {
  const slideSettings = {
    0: {
      slidesPerView: 1, //スライドの数
      spaceBetween: 0,
    },
    1024: {
      slidesPerView: 3,
      spaceBetween: 10,
    },
    1424: {
      slidesPerView: 3,
      spaceBetween: 10,
    },
    1824: {
      slidesPerView: 3,
      spaceBetween: 30,
    },
  };

  return (
    <Swiper
      modules={[Navigation, Pagination, Autoplay]}
      breakpoints={slideSettings} // slidesPerViewを指定
      slidesPerView={"auto"} // ハイドレーションエラー対策
      centeredSlides={true} // スライドを中央に配置
      loop={true} // スライドをループさせる
      speed={1000} // スライドが切り替わる時の速度
      autoplay={{
        delay: 2500,
        disableOnInteraction: false,
      }} // スライド表示時間
      navigation // ナビゲーション(左右の矢印)
      pagination={{
        clickable: true,
      }} // ページネーション, クリックで対象のスライドに切り替わる
      className="2xl:max-w-7xl xl:max-w-7xl lg:max-w-5xl md:max-w-3xl max-w-xs my-10 "
    >
      {images.map((src: string, index: number) => (
        <SwiperSlide key={index}>
          <a href={urls[index]} target="_blank" rel="noopener noreferrer">
            <Image
              src={src}
              width={400}
              height={300}
              alt={`Slider Image ${index + 1}`}
              sizes="sm:w-72 sm:h-32"
            />
          </a>
        </SwiperSlide>
      ))}
    </Swiper>
  );
}

こんな感じ。自動でバナーがスライドされます。
だんだんおしゃれになってきました。

image.png

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

続いてCardコンポーネントです。

app/components/home/Card.tsx
import React from "react";
import Image from "next/image";
import Link from "next/link";

type CardProps = {
  id: number;
  title: string;
  mainImage: string;
  content: string;
  tags: string[];
  category: string;
  updated_at: string;
  created_at: string;
};

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

export const Card: React.FC<CardProps> = ({
  id,
  title,
  mainImage,
  content,
  tags,
  category,
  updated_at,
  created_at,
}) => {
  // 日付をフォーマットする関数
  const formatDate = (dateString: string) => {
    return new Date(dateString).toLocaleString("ja-JP", {
      year: "numeric",
      month: "long",
      day: "numeric",
      hour: "numeric",
      minute: "numeric",
      second: "numeric",
    });
  };

  const isNew = (dateString: string) => {
    const updatedAtDate = new Date(dateString);
    const oneMonthAgo = new Date();
    oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); // 1ヶ月前の日付を取得

    return updatedAtDate > oneMonthAgo; // 更新日が1ヶ月前より後であればtrue
  };

  return (
    <Link href={`/articles/${id}`} passHref>
      <div className="max-w-sm bg-white border border-gray-200 rounded-lg shadow relative">
        <div className="relative h-48 rounded-t-lg overflow-hidden">
          <Image
            className="rounded-t-lg"
            src={mainImage}
            alt={title}
            layout="fill"
            objectFit="cover"
          />
          <div className="absolute top-3 left-3 bg-amber-500 text-white px-3 py-1 text-sm font-bold">
            {category}
          </div>
          {isNew(created_at) && (
            <div className="absolute top-3 right-3 bg-red-500 text-white px-3 py-1 text-sm font-bold">
              New
            </div>
          )}
        </div>

        <div className="p-5 mb-6">
          <div className="mb-2">
            {tags.map((tag, index) => (
              <span
                key={index}
                className="inline-block  rounded-full px-3 py-1 text-sm font-semibold border border-yellow-800 text-yellow-900 mr-2 mb-2"
              >
                #{tag}
              </span>
            ))}
          </div>
          <h5
            className="mb-2 text-2xl font-bold tracking-tight text-gray-900 overflow-hidden"
            style={{ maxHeight: "4rem", minHeight: "4rem" }}
          >
            {title}
          </h5>
          <p
            className="font-normal text-gray-400 overflow-hidden"
            style={{ maxHeight: "5.5rem" }}
          >
            {truncateText(content, 35)}
          </p>
        </div>

        <div className="absolute bottom-3 right-3 text-sm text-gray-600">
          {formatDate(updated_at)}
        </div>
      </div>
    </Link>
  );
};

formatDate関数は、与えられた日付文字列を日本のロケーションに合わせてフォーマットします。

isNew関数は、与えられた日付文字列が現在の日付から1ヶ月以内であるかどうかを判断します。この関数は、更新日が1ヶ月以内の場合にtrueを返し、それ以外の場合はfalseを返します。

こちらは記事作成日が1ヶ月以内であれば「New」というバッチを表示するために使用します。
最新記事であることがユーザー目線わかりやすくするためですね!

app/components/home/CardsContainer.tsx
import React from "react";
import { Card } from "./Card";
import { DummyData } from "../../util/constants";

export const CardsContainer = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
      {DummyData.map((data) => (
        <Card
          key={data.id}
          id={data.id}
          title={data.title}
          mainImage={data.mainImage}
          content={data.content}
          tags={data.tags}
          category={data.category}
          updated_at={data.updated_at}
          created_at={data.created_at}
        />
      ))}
    </div>
  );
};

続いてこちらは先ほど作成したCardコンポーネントをmap関数で展開しています。

app/util/constants.ts
type DummyDataType = {
  id: number;
  title: string;
  mainImage: string;
  tags: string[];
  category: string;
  content: string;
  created_at: string;
  updated_at: string;
};

export const DummyData: DummyDataType[] = [
  {
    id: 1,
    title: "【初心者必見】おすすめReact講座",
    mainImage: "/25330357_s 1.png",
    tags: ["React", "PHP"],
    category: "言語別おすすめ記事",
    content:
      "テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト",
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
  {
    id: 2,
    title: "PHP講座",
    mainImage: "/25330357_s 1.png",
    tags: ["React", "PHP"],
    category: "言語別おすすめ記事",
    content:
      "テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト",
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
  {
    id: 3,
    title: "Vueの入門講座",
    mainImage: "/25330357_s 1.png",
    tags: ["React", "Vue"],
    category: "言語別おすすめ記事",
    content:
      "テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト",
    created_at: new Date().toString(),
    updated_at: new Date().toString(),
  },
];

こちらはダミーデータですね。
まだmicroCMSのセットアップが完了していないので、デザイン確認のため出来るだけAPI構造に近い形でダミーデータを用意します。

⑤一覧ページの作成

続いて一覧ページの作成です。
先ほど作成したCardsContainerに手を加えていきます。

app/components/home/CardsContainer.tsx

"use client";

import React from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { useRouter } from "next/navigation";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import { Card, CardProps } from "./Card";

type CardsContainerProps = {
  cardsData: CardProps[];
  moreButtonUrl: string;
  categoryName: string;
  categoryIcon: React.ReactNode;
};

export const CardsContainer: React.FC<CardsContainerProps> = ({
  cardsData,
  moreButtonUrl,
  categoryName,
  categoryIcon,
}) => {
  const router = useRouter();

  const handleMoreButtonClick = () => {
    router.push(moreButtonUrl);
  };

  const slideSettings = {
    0: {
      slidesPerView: 1.2,
      spaceBetween: 10,
    },
    1024: {
      slidesPerView: 3,
      spaceBetween: 10,
    },
    1424: {
      slidesPerView: 3,
      spaceBetween: 10,
    },
    1824: {
      slidesPerView: 3.5,
      spaceBetween: 30,
    },
  };

  return (
    <div className="relative ">
      <div
        className="flex items-center absolute -top-14 left-6 px-4 font-semibold"
        style={{ zIndex: 50 }}
      >
        <span className="text-2xl mr-1">{categoryIcon}</span>
        <span
          onClick={handleMoreButtonClick}
          className="text-black py-2 cursor-pointer text-xl"
        >
          {categoryName}
        </span>
      </div>

      <Swiper
        modules={[Navigation, Pagination, Autoplay]}
        breakpoints={slideSettings}
        slidesPerView={"auto"}
        centeredSlides={true}
        loop={true}
        speed={1000}
        navigation
        pagination={{
          clickable: true,
        }}
        className="2xl:max-w-7xl xl:max-w-7xl lg:max-w-5xl md:max-w-3xl max-w-xs my-28"
      >
        {cardsData.map((data) => (
          <SwiperSlide key={data.id}>
            <Card
              id={data.id}
              title={data.title}
              mainImage={data.mainImage}
              content={data.content}
              tags={data.tags}
              category={data.category}
              updated_at={data.updated_at}
              created_at={data.created_at}
            />
          </SwiperSlide>
        ))}
      </Swiper>

      <button
        onClick={handleMoreButtonClick}
        className="absolute -bottom-10 right-6 text-blue-900 px-4 py-2 cursor-pointer underline"
        style={{ zIndex: 50 }}
      >
        もっと見る 
      </button>
    </div>
  );
};

handleMoreButtonClick関数は、「もっと見る」ボタンをクリックしたときに遷移するURLをコンポーネント使用先で定義できるようにpropsとして渡します。

slideSettingsはSwiperのスライダーをレスポンシブで、ブレークポイントごとに表示するスライドの数や間隔を指定しています。

そして下記app/page.tsxでこれらをカテゴリーごとに設置していきます。

app/page.tsx
import React from "react";
import { MainImage } from "./components/home/MainImage";
import Banners from "./components/home/Banners";
import { CardsContainer } from "./components/home/CardsContainer";
import { DummyData } from "./util/constants";
import { TbLanguageHiragana } from "react-icons/tb";
import { FaCrown } from "react-icons/fa6";
import { FaRoad } from "react-icons/fa";
import { FaSchool } from "react-icons/fa";
import { FaBook } from "react-icons/fa";
import { FaHandPaper } from "react-icons/fa";
import { FaPencilAlt } from "react-icons/fa";
import { FaYoutube } from "react-icons/fa";
import ContactUsComponent from "./components/home/ContactUsComponent";

export default function Home() {
  return (
    <div>
      {/* PC用の画像 */}
      <MainImage
        src="/home_image_pc.jpg"
        alt="home-image-pc"
        layout="fixed"
        className="hidden sm:block"
        width={2000}
        height={1000}
        priority
        textClassName="top-[25%] right-[5%] text-3xl"
      />
      {/* スマートフォン用の画像 */}
      <MainImage
        src="/home_image_sp.jpg"
        alt="home-image-sp"
        layout="fill"
        className="sm:hidden w-full h-80"
        textClassName="top-[25%] left-1/2 transform -translate-x-1/2 text-center text-xl w-3/4"
      />

      <Banners />

      <div className="bg-gradient-to-r from-blue-400 to-green-100 py-0.5">
        <CardsContainer
          cardsData={DummyData}
          moreButtonUrl="/desired-path"
          categoryName="閲覧ランキング"
          categoryIcon={<FaCrown />}
        />
      </div>
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="言語別おすすめ記事"
        categoryIcon={<TbLanguageHiragana />}
      />
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="必見ロードマップ"
        categoryIcon={<FaRoad />}
      />
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="スクールおすすめランキング"
        categoryIcon={<FaSchool />}
      />
      <div className="bg-gradient-to-r from-yellow-200 to-red-400 py-0.5">
        <CardsContainer
          cardsData={DummyData}
          moreButtonUrl="/desired-path"
          categoryName="特集"
          categoryIcon={<FaCrown />}
        />
      </div>
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="おすすめ書籍"
        categoryIcon={<FaBook />}
      />
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="おすすめ質問サイト"
        categoryIcon={<FaHandPaper />}
      />
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="独学おすすめサイト"
        categoryIcon={<FaPencilAlt />}
      />
      <CardsContainer
        cardsData={DummyData}
        moreButtonUrl="/desired-path"
        categoryName="おすすめYouTube"
        categoryIcon={<FaYoutube />}
      />

      <ContactUsComponent />
    </div>
  );
}

するとこんな感じで表示されます。
これでほぼほぼ一覧ページの完成です!

image.png

⑥詳細ページの作成

続いて詳細ページの作成です。
ここでは動的に記事のデータを取得してレンダリングします。

app/articles/[id]/page.tsx
"use client";

import React, { useEffect, useState } from "react";
import { DummyData, DummyDataType } from "../../util/constants";
import Image from "next/image";
import { useRouter, usePathname } from "next/navigation";

function ArticleDetail() {
  const router = useRouter();
  const pathname = usePathname();
  const [article, setArticle] = useState<DummyDataType | null>(null);

  useEffect(() => {
    // pathnameからIDを抽出する
    const match = pathname.match(/\/articles\/(\d+)/);
    if (match) {
      const id = parseInt(match[1]);
      const foundArticle = DummyData.find((article) => article.id === id);
      setArticle(foundArticle || null);
    }
  }, [pathname]);

  if (!article) {
    return <div>記事が見つかりません</div>;
  }

  return (
    <div className="xl:m-14 md:m-10 p-8 xl:p-12 md:p-10 border border-gray-300 bg-white">
      <div className="mb-2">
        {article.tags.map((tag, index) => (
          <span
            key={index}
            className="inline-block rounded-full px-3 py-1 text-sm font-semibold border border-yellow-800 text-yellow-900 mr-2 mb-2"
          >
            #{tag}
          </span>
        ))}
      </div>
      <div className="xl:flex justify-start items-center w-full pb-6">
        <h2 className="sm:pb-3 text-2xl font-bold flex-grow">
          {article.title}
        </h2>
        <div className="flex gap-4 items-center ml-auto">
          <div>
            <span>作成日:</span>
            {new Date(article.created_at).toLocaleDateString()}
          </div>
          <div>
            <span>更新日:</span>
            {new Date(article.updated_at).toLocaleDateString()}
          </div>
        </div>
      </div>

      <div className="relative w-full h-96">
        <Image
          src={article.mainImage}
          alt={article.title}
          layout="fill"
          objectFit="cover"
        />
      </div>

      <div className="bg-amber-500 text-white font-bold inline-block px-3 py-1 my-6">
        {article.category}
      </div>

      <div>
        <p>{article.content}</p>
      </div>
    </div>
  );
}

export default ArticleDetail;

URLのパス名から記事IDを抽出し、そのIDに基づいてDummyDataから該当するIDの記事データを取得します。
URLパスから記事IDを取得するためにuseRouterとusePathnameフックを使用しています。

image.png

雑だけど一旦こんな感じです。
コンテンツ内のデザインは多少microCMS側でいじれるはずなので、そこで細かいデザインなどは調整していきます。
なので一旦はこれでOKです!

⑦カテゴリー一覧ページの作成

続いてカテゴリー一覧の作成です。

app/util/constants.ts
export const CategoriesId: { [key: string]: string } = {
  0: "閲覧ランキング",
  1: "コラム",
  2: "言語別おすすめ記事",
  3: "必見ロードマップ",
  4: "スクールおすすめランキング",
  5: "おすすめ書籍",
  6: "おすすめ質問サイト",
  7: "独学おすすめサイト",
  8: "おすすめYouTube",
  9: "求人サイト",
  10: "転職対策",
  11: "ポートフォリオ",
};

因みにですがmicroCMSでIDはnumber型ではなくstring型で任意の文字列に設定することが可能です。

app/category/[id]/page.tsx
"use client";

import React, { useEffect, useState } from "react";
import { CategoriesId, DummyData, DummyDataType } from "../../util/constants";
import { Card } from "../../components/home/Card";
import { useRouter, usePathname } from "next/navigation";

function CategoryList() {
  const router = useRouter();
  const pathname = usePathname();
  const [categoryName, setCategoryName] = useState("");
  // カテゴリーに一致する記事のリスト
  const [categoryArticles, setCategoryArticles] = useState<DummyDataType[]>([]);

  useEffect(() => {
    // pathnameからIDを抽出
    const match = pathname.match(/\/category\/(\d+)/);
    if (match) {
      const id = match[1]; // URLから抽出したID
      const name = CategoriesId[id]; // IDに基づいてCategoriesIdからカテゴリー名を取得
      setCategoryName(name || "カテゴリーが見つかりません");

      // カテゴリー名に一致する記事をDummyDataから取得
      const articles = DummyData.filter((article) => article.category === name);
      setCategoryArticles(articles);
    }
  }, [pathname]);

  return (
    <div className="m-10">
      <h1 className="text-3xl font-bold mb-10">{categoryName}</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-16">
        {categoryArticles.map((article) => (
          <Card key={article.id} {...article} />
        ))}
      </div>
    </div>
  );
}

export default CategoryList;

useEffect内で、URLのパスからカテゴリーIDを正規表現で抽出し、CategoriesIdオブジェクトから対応するカテゴリー名を取得しています。そして、DummyData配列をフィルタリングして、選択されたカテゴリーの記事のみをcategoryArticlesにセットします。

こんな感じですね

image.png

⑧問い合わせページの作成

お問い合わせページはお問い合わせを受け付けるだけなので、全力で楽をしましょう。
みんな大好きGoogleFormで万事解決です!

作ったことがない方は下記を参考にしてみてください。簡単に作れます。

image.png

4.microCMSとの連携

①microCMS準備

続いてMicroCMSで記事を登録できるようにしていきます。
microCMSの登録がまだの方はユーザー登録からやってみてください。

microCMSの最初の設定は前回の記事で詳しく解説しているので、やってみてください。

設定が完了するとこんな感じで記事を作成します。
カテゴリーもついでに作成しておきましょう。

image.png

image.png

ちなみにAPIスキーマはこんな感じです。

image.png

②microCMSのセットアップ

さて、microCMS側の設定が完了したのでNext.jsの方にもセットアップしていきましょう。

まずはインストール。

npm install microcms-js-sdk

microcmsディレクトリを用意し、記事やカテゴリーを取得する処理を書いていきます。

app/lib/microcms/client.ts
import { ArticleType } from "@/app/types/ArticleType";
import { 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 getCategoryArticles = async (categoryId: string) => {
  const allArticles = await client.getList<ArticleType>({
    endpoint: "blogs",
    queries: {
      offset: 0,
      limit: 10,
      filters: `category[equals]${categoryId}`,
    },
  });

  return allArticles;
};

export const getDetailArticle = async (contentId: string) => {
  const detailArticle = await client.getListDetail<ArticleType>({
    endpoint: "blogs",
    contentId,
  });

  return detailArticle;
};

ここは少し丁寧に解説していきます。

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

createClient関数を使ってmicroCMSのAPIクライアントを生成します。これにはserviceDomainとapiKeyが必要です。
これはenvファイルで設定します。SERVICE_DOMAINは自分が設定したドメインを、API_KEYは「APIキー管理」にあるのでコピペしましょう。
下記のようなイメージです。

NEXT_PUBLIC_SERVICE_DOMAIN="creative-engineer"
NEXT_PUBLIC_API_KEY=""

export const getCategoryArticles = async (categoryId: string) => {
  const allArticles = await client.getList<ArticleType>({
    endpoint: "blogs",
    queries: {
      offset: 0,
      limit: 10,
      filters: `category[equals]${categoryId}`,
    },
  });

  return allArticles;
};

getCategoryArticles関数は、指定されたカテゴリーIDに基づいて記事一覧を取得します。これは非同期で実行され、ページネーションに対応しており、一度に最大10件の記事を取得します。

export const getDetailArticle = async (contentId: string) => {
  const detailArticle = await client.getListDetail<ArticleType>({
    endpoint: "blogs",
    contentId,
  });

  return detailArticle;
};

getDetailArticle関数は、指定された記事のIDをもとに詳細記事を取得します。

では早速これらの関数を使ってmicroCMSから記事を取得していきましょう。

③一覧ページのAPI結合

app/types/ArticleType.ts
export type ArticleType = {
  id: string;
  title: string;
  tags: string[];
  category: {
    id: string;
    name: string;
  };
  content: string;
  createdAt: string;
  updatedAt: string;
  eyecatch: {
    url: string;
    height: number;
    width: number;
  };
};
app/page.tsx
"use client";

import React, { useCallback, useEffect, useState } from "react";
import { MainImage } from "./components/home/MainImage";
import Banners from "./components/home/Banners";
import { CardsContainer } from "./components/home/CardsContainer";
import ContactUsComponent from "./components/home/ContactUsComponent";
import { getCategoryArticles } from "./lib/microcms/client";

// アイコンインポート
import { TbLanguageHiragana } from "react-icons/tb";
import { FaCrown } from "react-icons/fa6";
import { FaRoad } from "react-icons/fa";
import { FaSchool } from "react-icons/fa";
import { FaBook } from "react-icons/fa";
import { FaHandPaper } from "react-icons/fa";
import { FaPencilAlt } from "react-icons/fa";
import { FaYoutube } from "react-icons/fa";
import LoadingSpinner from "./loading";

export default function Home() {
  const [isLoading, setIsLoading] = useState(true);
  const [articles, setArticles] = useState({
    ranking: [],
    language: [],
    loadmap: [],
    school: [],
    book: [],
    question: [],
    study: [],
    youtube: [],
    job: [],
    jobchange: [],
    portfolio: [],
    column: [],
  });

  const fetchArticles = useCallback(async (categoryId: string) => {
    try {
      const data = await getCategoryArticles(categoryId);
      setArticles((prevArticles) => ({
        ...prevArticles,
        [categoryId]: data.contents,
      }));
    } catch (error) {
      console.error(`Error fetching ${categoryId} articles:`, error);
    }
  }, []);

  useEffect(() => {
    const categories = [
      "ranking",
      "language",
      "loadmap",
      "book",
      "question",
      "study",
      "school",
      "youtube",
      "job",
      "jobchange",
      "portfolio",
      "column",
    ];
    // すべてのカテゴリの記事を取得
    const fetchAllArticles = async () => {
      await Promise.all(
        categories.map((categoryId) => fetchArticles(categoryId))
      );
      setIsLoading(false);
    };

    fetchAllArticles();
  }, [fetchArticles]);

  if (isLoading) {
    return <LoadingSpinner />;
  }

  return (
    <div>

      <div className="bg-gradient-to-r from-blue-400 to-green-100 py-0.5">
        <CardsContainer
          cardsData={articles.ranking}
          moreButtonUrl="/category/ranking"
          categoryName="閲覧ランキング"
          categoryIcon={<FaCrown />}
        />
      </div>
      <CardsContainer
        cardsData={articles.language}
        moreButtonUrl="/category/language"
        categoryName="言語別おすすめ記事"
        categoryIcon={<TbLanguageHiragana />}
      />
      <CardsContainer
        cardsData={articles.loadmap}
        moreButtonUrl="/category/loadmap"
        categoryName="必見ロードマップ"
        categoryIcon={<FaRoad />}
      />
      <CardsContainer
        cardsData={articles.school}
        moreButtonUrl="/category/school"
        categoryName="スクールおすすめランキング"
        categoryIcon={<FaSchool />}
      />
      <div className="bg-gradient-to-r from-yellow-200 to-red-400 py-0.5">
        <CardsContainer
          cardsData={articles.column}
          moreButtonUrl="/category/column"
          categoryName="特集"
          categoryIcon={<FaCrown />}
        />
      </div>
      <CardsContainer
        cardsData={articles.book}
        moreButtonUrl="/category/book"
        categoryName="おすすめ書籍"
        categoryIcon={<FaBook />}
      />
      <CardsContainer
        cardsData={articles.question}
        moreButtonUrl="/category/question"
        categoryName="おすすめ質問サイト"
        categoryIcon={<FaHandPaper />}
      />
      <CardsContainer
        cardsData={articles.study}
        moreButtonUrl="/category/study"
        categoryName="独学おすすめサイト"
        categoryIcon={<FaPencilAlt />}
      />
      <CardsContainer
        cardsData={articles.youtube}
        moreButtonUrl="/category/youtube"
        categoryName="おすすめYouTube"
        categoryIcon={<FaYoutube />}
      />

      <ContactUsComponent />
    </div>
  );
}

fetchArticles関数では先ほど作成したgetCategoryArticles関数を呼び出して、指定されたカテゴリーIDに基づく記事データを非同期に取得しています。

useEffectP内では、マウント時にすべてのカテゴリーの記事データを取得する処理を実行しています。 categories配列には取得すべきすべてのカテゴリーIDが含まれており、Promise.all`を使用してこれらすべてのカテゴリーに対する記事データの取得を並行して実行します。

image.png

④カテゴリーページのAPI結合

app/category/[id]/page.tsx
"use client";

import React, { useEffect, useState, useCallback } from "react";
import { Card } from "../../components/home/Card";
import { useRouter, usePathname } from "next/navigation";
import { ArticleType } from "@/app/types/ArticleType";
import { getCategoryList } from "../../lib/microcms/client";
import LoadingSpinner from "@/app/loading";

function CategoryList() {
  const router = useRouter();
  const pathname = usePathname();
  const [categoryName, setCategoryName] = useState("");
  const [categoryArticles, setCategoryArticles] = useState<ArticleType[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  const fetchCategoryArticles = useCallback(async (categoryId: string) => {
    setIsLoading(true);
    try {
      const response = await getCategoryList(categoryId);
      if (response.contents && response.contents.length > 0) {
        const firstArticle = response.contents[0];
        const categoryName = firstArticle.category.name;
        setCategoryName(categoryName);
        setCategoryArticles(response.contents);
      } else {
        setCategoryName("カテゴリーが見つかりません");
        console.error("記事のデータが見つかりません");
      }
    } catch (error) {
      console.error("記事の取得に失敗しました", error);
    } finally {
      setIsLoading(false);
    }
  }, []);

  useEffect(() => {
    // URLからカテゴリーIDを抽出
    const match = pathname.match(/\/category\/([a-zA-Z]+)/);
    if (match) {
      const categoryId = match[1];
      fetchCategoryArticles(categoryId);
    }
  }, [pathname, fetchCategoryArticles]);

  if (isLoading) {
    return <LoadingSpinner />;
  }

  return (
    <div className="m-10">
      <h1 className="text-3xl font-bold mb-10">{categoryName}</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-16">
        {categoryArticles.map((article) => (
          <Card key={article.id} {...article} />
        ))}
      </div>
    </div>
  );
}

export default CategoryList;

fetchCategoryArticlesではカテゴリーIDを引数として受け取り、そのIDに基づいてgetCategoryList関数を呼び出してカテゴリーに属する記事リストを取得します。
そして取得したデータの中から最初の記事のカテゴリー名を取得してcategoryNameを更新し、取得した記事リストをcategoryArticlesにセットします。

useEffectでは、URLのパス名からカテゴリーIDを正規表現を使用して抽出し、そのIDをfetchCategoryArticles関数に渡して記事データの取得を実行しています。

ついでにサイドバーのリンクも修正します。

app/components/SideBar.tsx
<ul>
        <SidebarLink
          href={`/category/language`}
          icon={TbLanguageHiragana}
          selectedOption={selectedOption}
        >
          言語別おすすめ記事
        </SidebarLink>
        <SidebarLink
          href={`/category/loadmap`}
          icon={FaRoad}
          selectedOption={selectedOption}
        >
          必見ロードマップ
        </SidebarLink>
        <SidebarLink
          href={`/category/school`}
          icon={FaSchool}
          selectedOption={selectedOption}
        >
          スクールおすすめランキング
        </SidebarLink>
        <SidebarLink
          href={`/category/book`}
          icon={FaBook}
          selectedOption={selectedOption}
        >
          おすすめ書籍
        </SidebarLink>
        <SidebarLink
          href={`/category/question`}
          icon={FaHandPaper}
          selectedOption={selectedOption}
        >
          おすすめ質問サイト
        </SidebarLink>
        <SidebarLink
          href={`/category/study`}
          icon={FaPencilAlt}
          selectedOption={selectedOption}
        >
          独学おすすめサイト
        </SidebarLink>
        <SidebarLink
          href={`/category/youtube`}
          icon={FaYoutube}
          selectedOption={selectedOption}
        >
          おすすめYouTube
        </SidebarLink>
      </ul>

image.png

⑤詳細ページのAPI結合

app/articles/[id]/page.tsx
"use client";

import React, { useEffect, useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import { ArticleType } from "@/app/types/ArticleType";
import { getDetailArticle } from "../../lib/microcms/client";
import Image from "next/image";
import LoadingSpinner from "@/app/loading";

function ArticleDetail() {
  const pathname = usePathname();
  const [article, setArticle] = useState<ArticleType | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const fetchArticleDetail = useCallback(async (contentId: string) => {
    setIsLoading(true);
    try {
      const detailArticle = await getDetailArticle(contentId);
      setArticle(detailArticle);
    } catch (error) {
      console.error("記事の詳細の取得に失敗しました", error);
    } finally {
      setIsLoading(false);
    }
  }, []);

  useEffect(() => {
    // URLからIDを抽出する
    const match = pathname.match(/\/articles\/([a-zA-Z0-9-]+)/);
    if (match) {
      const contentId = match[1];
      fetchArticleDetail(contentId);
    }
  }, [pathname, fetchArticleDetail]);

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (!article) {
    return <div className="m-10">記事が見つかりません</div>;
  }

  return (
    <div className="xl:m-14 md:m-10 p-8 xl:p-12 md:p-10 border border-gray-300 bg-white">
      <div className="mb-2">
        {article.tags.map((tag, index) => (
          <span
            key={index}
            className="inline-block rounded-full px-3 py-1 text-sm font-semibold border border-yellow-800 text-yellow-900 mr-2 mb-2"
          >
            #{tag}
          </span>
        ))}
      </div>
      <div className="xl:flex justify-start items-center w-full pb-6">
        <h2 className="sm:pb-3 text-2xl font-bold flex-grow">
          {article.title}
        </h2>
        <div className="flex gap-4 items-center ml-auto">
          <div>
            <span>作成日:</span>
            {new Date(article.createdAt).toLocaleDateString()}
          </div>
          <div>
            <span>更新日:</span>
            {new Date(article.updatedAt).toLocaleDateString()}
          </div>
        </div>
      </div>
      <div className="relative w-full h-96">
        <Image
          src={article.eyecatch.url}
          alt={article.title}
          layout="fill"
          objectFit="cover"
        />
      </div>
      <div className="bg-amber-500 text-white font-bold inline-block px-3 py-1 my-6">
        {article.category.name}
      </div>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </div>
  );
}

export default ArticleDetail;

詳細ページも一覧ページと同様ですね。
記事IDを引数として受け取り、getDetailArticle関数を非同期に呼び出して記事の詳細データを取得しています。

出来上がりはこんな感じ!

image.png

⑥お気に入り機能の実装

さて、それではいよいよ最後の機能実装です。
ユーザー登録してくれたユーザーさんには、お気に入りした記事を後で見返せるように、お気に入り機能を実装してみましょう!

まずはお気に位置状態をチェックするAPIの作成です。

app/api/favorite/check/route.ts

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

/*
 * お気に入りがDBに存在するか確認。存在すればtrueを存在しなければfalseを返す
 */
export async function POST(request: Request) {
  if (request.method !== "POST") {
    return new Response(JSON.stringify({ message: "Method Not Allowed" }), {
      status: 405,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }

  const { articleId, userId } = await request.json();

  if (!userId || !articleId) {
    return new Response(
      JSON.stringify({ message: "userIdまたはarticleIdが不足しています" }),
      {
        status: 400,
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
  }

  try {
    const favoriteExists = await prisma.favorite.findUnique({
      where: {
        userId_articleId: {
          userId: userId,
          articleId: articleId,
        },
      },
    });

    return new Response(JSON.stringify({ isFavorite: !!favoriteExists }), {
      status: 200,
      headers: {
        "Content-Type": "application/json",
      },
    });
  } catch (error: any) {
    console.error(error);
    return new Response(
      JSON.stringify({
        message: "サーバーエラーが発生しました: " + error.message,
      }),
      {
        status: 500,
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
  }
}

まずPrisma Clientは、Prisma ORMを使用してデータベースの操作を行うためのクライアントで、インスタンス化して使用しています。

if (request.method !== "POST") {
    return new Response(JSON.stringify({ message: "Method Not Allowed" }), {
      status: 405,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }

そしてリクエストがPOSTメソッドかどうかを確認します。POSTメソッドでない場合は、405(Method Not Allowed)ステータスとともにエラーメッセージを返します。

const { articleId, userId } = await request.json();

request.json()を使用してリクエストボディからarticleIduserIdを取得します。どちらかが不足している場合は、400(Bad Request)ステータスとともにエラーメッセージを返します。

try {
    const favoriteExists = await prisma.favorite.findUnique({
      where: {
        userId_articleId: {
          userId: userId,
          articleId: articleId,
        },
      },
    });
return new Response(JSON.stringify({ isFavorite: !!favoriteExists }), {
      status: 200,
      headers: {
        "Content-Type": "application/json",
      },
    });

prisma.favorite.findUniqueメソッドを使用して、userIdarticleIdに基づいてお気に入り情報がデータベースに存在するかどうかを確認します。このクエリは、userIdarticleIdの組み合わせでユニークなお気に入りレコードを検索します。

お気に入り情報が存在するかどうかに応じて、レスポンスボディにisFavoriteフラグを含むJSONを設定して返します。お気に入りが存在すればtrue、存在しなければfalseを返します。

つまり、このコードではお気に入りがDBに存在するか確認し、存在すればtrueを、存在しなければfalseを返すように記述しています。

因みにですが、Next.jsにおいて、APIルーティングはpages/apiディレクトリ内のファイルを通じて行われます。ここに配置された各ファイルは、/api/*のパスにマッピングされ、APIエンドポイントとして扱われます。

続いて、お気に入りがDBに存在しなければお気に入り登録し、存在すればお気に入り削除するAPIの実装していきます。

app/api/favorite/route.ts
import { PrismaClient } from "@prisma/client";
import { NextResponse } from "next/server";

const prisma = new PrismaClient();

/*
 * お気に入りがDBに存在しなければお気に入り登録し、存在すればお気に入り削除
 */
export async function POST(request: Request) {
  const requestBody = await request.text();
  const { articleId, userId } = JSON.parse(requestBody);

  if (!userId || !articleId) {
    return NextResponse.json(
      { message: "userIdまたはarticleIdが不足しています" },
      { status: 400 }
    );
  }

  try {
    // データベースにお気に入りが存在するか確認
    const favoriteExists = await prisma.favorite.findUnique({
      where: {
        userId_articleId: {
          userId: userId,
          articleId: articleId,
        },
      },
    });

    if (favoriteExists) {
      // お気に入りが存在する場合は削除
      await prisma.favorite.delete({
        where: {
          userId_articleId: {
            userId: userId,
            articleId: articleId,
          },
        },
      });
      return NextResponse.json(
        { message: "お気に入りを解除しました" },
        { status: 200 }
      );
    } else {
      // お気に入りが存在しない場合は作成
      await prisma.favorite.create({
        data: {
          userId: userId,
          articleId: articleId,
        },
      });
      return NextResponse.json(
        { message: "お気に入りに追加しました" },
        { status: 200 }
      );
    }
  } catch (error: any) {
    console.error(error);
    return NextResponse.json(
      { message: "サーバーエラーが発生しました: " + error.message },
      { status: 500 }
    );
  }
}

前半部分は先程のコードと一緒ですね。

const requestBody = await request.text();
const { articleId, userId } = JSON.parse(requestBody);

上記では、リクエストボディの内容をテキストとして非同期に読み込みます。
そして、テキスト形式のリクエストボディをJSONオブジェクトに変換し、分割代入でarticleIduserIdを取り出しています。

const favoriteExists = await prisma.favorite.findUnique({
      where: {
        userId_articleId: {
          userId: userId,
          articleId: articleId,
        },
      },
    });

こちらは先ほどのコードと同様ですね。
指定されたuserIdarticleIdの組み合わせに該当するお気に入り情報がデータベースに存在するか確認します。

if (favoriteExists) {
      // お気に入りが存在する場合は削除
      await prisma.favorite.delete({
        where: {
          userId_articleId: {
            userId: userId,
            articleId: articleId,
          },
        },
      });
      return NextResponse.json(
        { message: "お気に入りを解除しました" },
        { status: 200 }
      );

そして、お気に入りが既に存在する場合は、そのお気に入りをデータベースから削除します。

// お気に入りが存在しない場合は作成
      await prisma.favorite.create({
        data: {
          userId: userId,
          articleId: articleId,
        },
      });
      return NextResponse.json(
        { message: "お気に入りに追加しました" },
        { status: 200 }
      );

お気に入りが存在しない場合は、新たにお気に入りとしてデータベースに登録します。

これでAPI部分の実装は完了しました!
早速これらのAPIを利用してクライアントからお気に入り操作をしていきましょう。

まずは、記事詳細ページにお気に入り機能の実装していきます。

app/articles/[id]/page.tsx

"use client";

import React, { useEffect, useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import { ArticleType } from "@/app/types/ArticleType";
import { getDetailArticle } from "../../lib/microcms/client";
import Image from "next/image";
import LoadingSpinner from "@/app/loading";
import { useSession } from "next-auth/react";
import { FaHeart } from "react-icons/fa";
import { IoIosHeartEmpty } from "react-icons/io";

function ArticleDetail() {
  const pathname = usePathname();
  const [article, setArticle] = useState<ArticleType | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isFavorite, setIsFavorite] = useState(false);

  const { data: session, status } = useSession();

  const fetchArticleDetail = useCallback(async () => {
    const match = pathname.match(/\/articles\/([a-zA-Z0-9-]+)/);
    if (match && session) {
      const contentId = match[1];
      setIsLoading(true);
      try {
        const detailArticle = await getDetailArticle(contentId);
        setArticle(detailArticle);
        // お気に入りの状態を確認
        if (session?.user?.id && detailArticle.id) {
          try {
            const response = await fetch(`/api/favorite/check`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                articleId: detailArticle.id,
                userId: session.user.id,
              }),
            });
            if (!response.ok) {
              throw new Error("お気に入りの状態の確認に失敗しました");
            }
            const data = await response.json();
            setIsFavorite(data.isFavorite);
          } catch (error) {
            console.error("お気に入りの状態の確認に失敗しました", error);
          }
        }
      } catch (error) {
        console.error("記事の詳細の取得に失敗しました", error);
      } finally {
        setIsLoading(false);
      }
    }
  }, [pathname, session]);

  useEffect(() => {
    // sessionが"取得できたらfetchArticleDetailを実行
    if (status !== "loading") {
      fetchArticleDetail();
    }
  }, [fetchArticleDetail, status]);

  const handleFavoriteClick = async () => {
    if (!article?.id || !session?.user?.id) {
      console.error("articleIdまたはuserIdがundefinedです。");
      return;
    }
    // APIコール前に状態をトグルする
    setIsFavorite((current) => !current);

    try {
      const response = await fetch("/api/favorite", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          articleId: article.id,
          userId: session.user.id,
        }),
      });

      if (!response.ok) {
        throw new Error("サーバーからのレスポンスがokではありません。");
      }
    } catch (error) {
      console.error("お気に入りの状態の更新に失敗しました", error);
      // エラーが発生した場合、状態を元に戻す
      setIsFavorite((current) => !current);
    }
  };

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (!article) {
    return <div className="m-10">記事が見つかりません</div>;
  }

  return (
    <div className="xl:m-14 md:m-10 p-8 xl:p-12 md:p-10 border border-gray-300 bg-white">
      <div className="flex justify-between items-center mb-2">
        <div className="flex flex-wrap">
          {article.tags.map((tag, index) => (
            <span
              key={index}
              className="inline-block rounded-full px-3 py-1 text-sm font-semibold border border-yellow-800 text-yellow-900 mr-2 mb-2"
            >
              #{tag}
            </span>
          ))}
        </div>

        <button
          onClick={handleFavoriteClick}
          className={`rounded-full p-2 transition duration-150 ease-in-out transform ${
            isFavorite
              ? "border border-red-500 scale-110"
              : "border border-gray-400"
          }`}
        >
          {isFavorite ? (
            <FaHeart color="#ef4444" size="27" />
          ) : (
            <IoIosHeartEmpty color="#9ca3af" size="27" />
          )}
        </button>
      </div>
      // 省略
    </div>
  );
}

export default ArticleDetail;
const { data: session, status } = useSession();

useSessionフックを使用して、現在のユーザーセッション情報(session)とそのステータスを取得します。

const fetchArticleDetail = useCallback(async () => {
    const match = pathname.match(/\/articles\/([a-zA-Z0-9-]+)/);
    if (match && session) {
      const contentId = match[1];
      setIsLoading(true);
      try {
        const detailArticle = await getDetailArticle(contentId);
        setArticle(detailArticle);
        // お気に入りの状態を確認
        if (session?.user?.id && detailArticle.id) {
          try {
            const response = await fetch(`/api/favorite/check`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                articleId: detailArticle.id,
                userId: session.user.id,
              }),
            });
            if (!response.ok) {
              throw new Error("お気に入りの状態の確認に失敗しました");
            }
            const data = await response.json();
            setIsFavorite(data.isFavorite);
          } catch (error) {
            console.error("お気に入りの状態の確認に失敗しました", error);
          }
        }
      } catch (error) {
        console.error("記事の詳細の取得に失敗しました", error);
      } finally {
        setIsLoading(false);
      }
    }
  }, [pathname, session]);

まずはpathname.matchで現在のURLから記事のIDを正規表現を用いて抽出します。
そして、抽出された記事ID(contentId)をgetDetailArticle関数に渡して、記事の詳細データを非同期に取得します。

ログイン中のユーザーID(session.user.id)と記事ID(detailArticle.id)を使用して、お気に入り状態をチェックするPOSTリクエストを送信します。先程作成したAPIですね。
/api/favorite/checkのエンドポイントに対してリクエストします。

const handleFavoriteClick = async () => {
    if (!article?.id || !session?.user?.id) {
      console.error("articleIdまたはuserIdがundefinedです。");
      return;
    }
    // APIコール前に状態をトグルする
    setIsFavorite((current) => !current);

    try {
      const response = await fetch("/api/favorite", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          articleId: article.id,
          userId: session.user.id,
        }),
      });

      if (!response.ok) {
        throw new Error("サーバーからのレスポンスがokではありません。");
      }
    } catch (error) {
      console.error("お気に入りの状態の更新に失敗しました", error);
      // エラーが発生した場合、状態を元に戻す
      setIsFavorite((current) => !current);
    }
  };

こちらはユーザーがお気に入りボタンをクリックしたときにこの関数が呼び出されます。

setIsFavoriteを使用してお気に入りの状態をトグルします。これは、APIコールを行う前にUIを即座に更新します。
普通はAPIからレスポンスが返ってくるまで待つのですが、お気に入りに限っては押されたタイミングですぐにUIが変わる場合が多いですよね。Qiita然りX然り。
そちらの方がユーザ目線分かりやすいので、今回その仕様にしました。

そして/api/favoriteエンドポイントに対してPOSTリクエストを送信します。このリクエストのボディには、現在の記事ID(article.id)とユーザーID(session.user.id)を含めてリクエストします。これは、お気に入りの追加または削除の処理を行うための情報ですね。

お気に入りの追加または削除自体の処理は、先ほどのAPIの実装でやりましたね。

画面としてはこんな感じでお気に入り機能の実装ができました!

image.png

⑦マイページにお気に入りした記事一覧を表示

さて、ではお気に入り登録できるようになったので、最後にそれをマイページで表示できるようにしましょう。
まずはAPIの実装です。

app/api/favorite/list/route.ts

import { getDetailArticle } from "@/app/lib/microcms/client";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

/*
 * ユーザーのお気に入り記事一覧を取得
 */
export async function GET(request: Request) {
  if (request.method !== "GET") {
    return new Response(JSON.stringify({ message: "Method Not Allowed" }), {
      status: 405,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }

  const url = new URL(request.url);
  const userId = url.searchParams.get("userId");

  if (!userId) {
    return new Response(JSON.stringify({ message: "userIdが不足しています" }), {
      status: 400,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }

  try {
    const favorites = await prisma.favorite.findMany({
      where: { userId: userId },
    });

    // 各お気に入り記事の詳細を非同期に取得
    const articlesDetails = await Promise.all(
      favorites.map((favorite) => getDetailArticle(favorite.articleId))
    );

    return new Response(JSON.stringify({ favorites, articlesDetails }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  } catch (error: any) {
    console.error(error);
    return new Response(
      JSON.stringify({
        message: "サーバーエラーが発生しました: " + error.message,
      }),
      {
        status: 500,
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
  }
}

リクエストのURLからuserIdクエリパラメータを取得します。これは、お気に入り記事を取得したいユーザーのIDです。
userIdがリクエストに含まれていない場合は、400(Bad Request)ステータスとエラーメッセージを含むレスポンスを返します。

Prisma ClientのfindManyメソッドを使用して、指定されたuserIdに紐づくお気に入りの記事の一覧をデータベースから非同期に取得します。

app/mypage/page.tsx

"use client";

import React, { useEffect, useState } from "react";
import Image from "next/image";
import { useSession } from "next-auth/react";
import { ArticleType } from "../types/ArticleType";
import { Card } from "../components/home/Card";

const MyPage = () => {
  const { data: session } = useSession();
  const [articlesDetails, setArticlesDetails] = useState<ArticleType[]>([]);

  useEffect(() => {
    const fetchFavorites = async () => {
      if (session?.user?.id) {
        const response = await fetch(
          `/api/favorite/list?userId=${session.user.id}`
        );
        if (response.ok) {
          const data = await response.json();
          setArticlesDetails(data.articlesDetails);
        } else {
          console.error("お気に入り記事の取得に失敗しました");
        }
      }
    };

    fetchFavorites();
  }, [session]);

  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={session?.user?.image || "/default_icon.png"}
            alt="user profile icon"
            width={60}
            height={60}
            className="rounded-full"
          />
          <h2 className="text-lg ml-4 font-semibold">
            お名前{session?.user?.name}
          </h2>
        </div>
      </div>

      <span className="font-medium text-lg mb-10 mt-10 block">
        お気に入りした記事
      </span>
      <div className="flex items-center mb-20">
        {articlesDetails.length > 0 ? (
          <div className="flex flex-wrap items-center gap-6">
            {articlesDetails.map((article) => (
              <Card key={article.id} {...article} />
            ))}
          </div>
        ) : (
          <p>まだお気に入りした記事がありません</p>
        )}
      </div>
    </div>
  );
};

export default MyPage;

useSessionから返されるsessionオブジェクトを取得し、現在のユーザーセッション情報を取得します。

APIからのレスポンスが成功すれば、その中のarticlesDetailsプロパティからお気に入り記事の詳細情報を取得します。
取得した記事の詳細情報はsetArticlesDetailsを使用してStateにセットされ、JSX内でmap関数で展開していきます。

これでマイページにお気に入り登録した記事一覧が表示できるようになりました!

image.png

4.デプロイ

ビルド

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

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

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

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

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

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

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

image.png

本番の環境変数の設定

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

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

image.png

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

image.png

おわりに

ここまでクソ長い記事を読んでくれてありがとうございました!

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

参考

9
7
0

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
9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?