はじめに
皆さんこんにちは、mamiと申します。
今回は個人開発として駆け出しエンジニア支援サイトを作ってみたので、その技術スタックやコード解説なんかをしていこうかなと思います!
バックエンドは用意せずに全てNext.jsとライブラリで完結しているので、開発方法を見るだけでも面白いのではないのかなと思います。
また、最新のNext14のApp routerで開発を行っているので、最新のNext.jsってどんな事してるの?という方にも刺さるのではないでしょうか。
どんなアプリを作ったのか?
早速ですが、作ったアプリはデプロイしているので気になる人は下記を実際に見てみてください。
完結に説明すると、駆け出しエンジニアのための情報集約サイトになります!
言語別のおすすめ記事や、ロードマップ、スクールについてなどをまとめております。
会員登録不要で利用することができ、会員登録するとお気に入り記事を登録することができたり、新着記事などが投稿されるとメール通知を行ったりなどの機能が利用できます。
バタバタしていて中身のコンテンツはまだ空っぽ状態です!
今回はあくまで側のみを開発したので、サービスとしての運用はこれからやっていく予定です。なので設計や技術面にフォーカスした記事になります!
細かい使用技術などは後ほど丁寧に解説いきます。
コードを見てみたい方は下記に公開しているので見てみてください。
技術構成
フロント | 技術 |
---|---|
フロントエンド | TypeScript, Next14(app router), TailwindCSS |
認証 | NextAuth.js |
データベース | PostgreSQL |
ORM | Prisma |
インフラ | Vercel |
DB設計
User テーブル
フィールド | データ型 | 制約 | 説明 |
---|---|---|---|
id | String | プライマリーキー、デフォルト値(cuid()) | ユーザーID |
name | String | オプショナル | ユーザー名 |
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のセットアップをしながらついでに最初のデプロイもやっておきます。
下記にアクセスして、ユーザー登録がまだの方は最初にやっておいてください。
デプロイ手順は下記の手順で行えます。
- サインアップ画面での右上にある"New Repository"をクリックする
- 画面左の"Import Git Repository"から、公開したいプログラムを選択する
- "Select Vercel Scope"で、公開元となるアカウントを選択する
- "Project Name"を指定し、右下の"Deploy"をクリックする
こんな画面が出てきたらデプロイ成功です!めちゃくちゃ簡単ですね!
上記完了したらpostgresの設定をします。
Storage から「Create database」で postgres を選択してDB作成。
Connect Project で先ほどのプロジェクトを選択。下記のような画面が出てきたらOKです。
そして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の接続情報を貼り付けてください。
これで 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認証用の設定をしていきます。
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_ID
やGITHUB_SECRET
はenvファイルで定義しておきます。
どこの値を参照すればいいのかと言うと、
GitHubのsettingsから「Developer settings」→OAuthAppの「New OAuth App」から作成できます。
下記のような感じで設定します。
作成できたら、「Client ID」と「Client secrets」が発行されるので、それをそのままenvファイルに設定するだけでOKです。
Prismaのセットアップ
続いてPrismaのセットアップをしていきます。
npm install prisma --save-dev
// 初期化
npx prisma init
npm i @prisma/client
上記で必要なファイルなどが自動作成されたと思うので、prisma/schema.prisma
を開いて下記のように設定し、DBの接続情報をそれぞれ記述します。
// 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
のインスタンスをグローバルに管理するためのものです。
グローバル変数を使用することで、アプリケーション全体で同じインスタンスを再利用できます。
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で管理していくので、ここでは定義しません。
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です!
認証用APIの作成
ここまででDBの準備も整ったので、いよいよ本題のログイン機能を実装していきます。
app/api/auth/[...nextauth]
ディレクトリにroute.ts
を作成します。
ちなみにここの...nextauth
というのは、公式ドキュメントによると「...APIルートは括弧内に3つのドットを追加することで、全てのパスをキャッチするように拡張できます」とのことだそうです。
つまりここでいうと、authディレクトリの配下全てにAPIを適用させる、と言うことみたいです。
そして中身は下記のように記述します。
これはRoute Handlers機能を使用してNextAuth.jsを初期化しています。
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
を利用して、セッション管理のコンテキストを提供するカスタムプロバイダコンポーネントを定義しています。
"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.tsx
でNextAuthProvider
をラップするのを忘れずに。
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認証の設定も追加していきます。
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,
};
ログインページはこんな感じで作っていきます。
"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の中の「認証情報」から設定することができます。
最後の画面までいけたらOKです!
認証情報の取得
認証情報(クライアントIDとクライアントシークレット)を取得します。
envファイルに書くやつですね。
envファイルにそれぞれ貼り付けてください。
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
上手くいかなかった方は詳しくは下記記事を参照してみてください。
では実際にログインしてみましょう。
ログインボタンを押して、下記のような画面でGitHub認証が出来るようになりました!
Google認証はこんな感じ
ちなみにログイン画面はこんな感じです!
3.フロントエンド開発
では認証機能が実装出来たところで、早速画面の方も作っていきます!
①ヘッダー・フッターの作成
appディレクトリの配下にcomponentsを作成し、その中に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;
そして取得したユーザー情報を見てログイン状態を判断したり、アイコン表示に利用したりしています。
続いてフッターコンポーネントの作成です。
こちらはデザインの表示のみなので特に解説はないです。
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;
と言うことで画面はこんな感じになりました。
ちなみに今回は「シンプルで直感的に使いやすいデザイン」を意識して作成しています。
②サイドバーの作成
続いてサイドバーの作成です。
useState
やuseEffect
などを使って状態を管理していきたいので、このコンポーネントはClientComponentsで書いていきます。
"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>
);
}
こんな感じになりました。
カテゴリーをクリックすると、そのカテゴリーがマークアップされて、今どのカテゴリーを閲覧しているのかが視覚的にわかりやすくなります。
③バナー画像(スライダー)
スライダーは安定のSwiperを使用します。
導入手順や細かい仕様については公式ドキュメントをご参照ください。
下記にコメント追記してどう言うプロパティなのかはザッと記しておきます。
対応するURL部分については記事のURLが確定したら書き換えていきます。
バナーのURLはそうそう変わることはないので、この仕様にしました。
"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>
);
}
こんな感じ。自動でバナーがスライドされます。
だんだんおしゃれになってきました。
④Cardコンポーネントの作成
続いてCardコンポーネントです。
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」というバッチを表示するために使用します。
最新記事であることがユーザー目線わかりやすくするためですね!
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関数で展開しています。
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に手を加えていきます。
"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
でこれらをカテゴリーごとに設置していきます。
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>
);
}
するとこんな感じで表示されます。
これでほぼほぼ一覧ページの完成です!
⑥詳細ページの作成
続いて詳細ページの作成です。
ここでは動的に記事のデータを取得してレンダリングします。
"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フックを使用しています。
雑だけど一旦こんな感じです。
コンテンツ内のデザインは多少microCMS側でいじれるはずなので、そこで細かいデザインなどは調整していきます。
なので一旦はこれでOKです!
⑦カテゴリー一覧ページの作成
続いてカテゴリー一覧の作成です。
export const CategoriesId: { [key: string]: string } = {
0: "閲覧ランキング",
1: "コラム",
2: "言語別おすすめ記事",
3: "必見ロードマップ",
4: "スクールおすすめランキング",
5: "おすすめ書籍",
6: "おすすめ質問サイト",
7: "独学おすすめサイト",
8: "おすすめYouTube",
9: "求人サイト",
10: "転職対策",
11: "ポートフォリオ",
};
因みにですがmicroCMSでIDはnumber型ではなくstring型で任意の文字列に設定することが可能です。
"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にセットします。
こんな感じですね
⑧問い合わせページの作成
お問い合わせページはお問い合わせを受け付けるだけなので、全力で楽をしましょう。
みんな大好きGoogleFormで万事解決です!
作ったことがない方は下記を参考にしてみてください。簡単に作れます。
4.microCMSとの連携
①microCMS準備
続いてMicroCMSで記事を登録できるようにしていきます。
microCMSの登録がまだの方はユーザー登録からやってみてください。
microCMSの最初の設定は前回の記事で詳しく解説しているので、やってみてください。
設定が完了するとこんな感じで記事を作成します。
カテゴリーもついでに作成しておきましょう。
ちなみにAPIスキーマはこんな感じです。
②microCMSのセットアップ
さて、microCMS側の設定が完了したのでNext.jsの方にもセットアップしていきましょう。
まずはインストール。
npm install microcms-js-sdk
microcmsディレクトリを用意し、記事やカテゴリーを取得する処理を書いていきます。
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結合
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;
};
};
"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`を使用してこれらすべてのカテゴリーに対する記事データの取得を並行して実行します。
④カテゴリーページのAPI結合
"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
関数に渡して記事データの取得を実行しています。
ついでにサイドバーのリンクも修正します。
<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>
⑤詳細ページのAPI結合
"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
関数を非同期に呼び出して記事の詳細データを取得しています。
出来上がりはこんな感じ!
⑥お気に入り機能の実装
さて、それではいよいよ最後の機能実装です。
ユーザー登録してくれたユーザーさんには、お気に入りした記事を後で見返せるように、お気に入り機能を実装してみましょう!
まずはお気に位置状態をチェックするAPIの作成です。
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()
を使用してリクエストボディからarticleId
とuserId
を取得します。どちらかが不足している場合は、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
メソッドを使用して、userId
とarticleId
に基づいてお気に入り情報がデータベースに存在するかどうかを確認します。このクエリは、userId
とarticleId
の組み合わせでユニークなお気に入りレコードを検索します。
お気に入り情報が存在するかどうかに応じて、レスポンスボディにisFavorite
フラグを含むJSONを設定して返します。お気に入りが存在すればtrue
、存在しなければfalse
を返します。
つまり、このコードではお気に入りがDBに存在するか確認し、存在すればtrue
を、存在しなければfalse
を返すように記述しています。
因みにですが、Next.jsにおいて、APIルーティングはpages/api
ディレクトリ内のファイルを通じて行われます。ここに配置された各ファイルは、/api/*
のパスにマッピングされ、APIエンドポイントとして扱われます。
続いて、お気に入りがDBに存在しなければお気に入り登録し、存在すればお気に入り削除するAPIの実装していきます。
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オブジェクトに変換し、分割代入でarticleId
とuserId
を取り出しています。
const favoriteExists = await prisma.favorite.findUnique({
where: {
userId_articleId: {
userId: userId,
articleId: articleId,
},
},
});
こちらは先ほどのコードと同様ですね。
指定されたuserId
とarticleId
の組み合わせに該当するお気に入り情報がデータベースに存在するか確認します。
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を利用してクライアントからお気に入り操作をしていきましょう。
まずは、記事詳細ページにお気に入り機能の実装していきます。
"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の実装でやりましたね。
画面としてはこんな感じでお気に入り機能の実装ができました!
⑦マイページにお気に入りした記事一覧を表示
さて、ではお気に入り登録できるようになったので、最後にそれをマイページで表示できるようにしましょう。
まずはAPIの実装です。
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
に紐づくお気に入りの記事の一覧をデータベースから非同期に取得します。
"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関数で展開していきます。
これでマイページにお気に入り登録した記事一覧が表示できるようになりました!
4.デプロイ
ビルド
これでようやく全ページ実装完了することが出来ました!
では完成したところでVercelにデプロイしていきます!
初期デプロイはVercel Postgresのセットアップの際に既に行なっているので、後はGitHubにプッシュするだけですね。
その前にpackage.json
のbuildコマンドを以下
"build": "prisma generate && next build ",
のように変えておきます。これをしないとビルドの際にコケてしまうので、これで成功するはずです。
実際にプッシュするだけで自動デプロイされるはずなので、Vercelを確認してみましょう。
下記のような表示になっていたら無事デプロイ成功ですね!
本番の環境変数の設定
ではデプロイ自体は成功しましたが、まだ本番の環境変数を設定できていないので動かないと思います。
なので環境変数を設定しましょう。
VercelのSettingsの中の「Environment Variables」で環境変数を設定できます。
追加する環境変数はenvファイルに記載してるものをそのまま追加していくだけです!
と言うことで無事本番反映まで完了できました!
おわりに
ここまでクソ長い記事を読んでくれてありがとうございました!
昨今のNext.jsの進化が止まらないので置いていかれないように最新情報をキャッチアップしつつ、積極的に活用していきたいと思います!
参考