Next.jsとmicroCMSでブログを作る

更新:2025-03-31
JavaScript

概要

見た聞い読んだを個人的にまとめておくブログを、Next.jsmicroCMSで作ってみる。

サンプル記事の作成

microCMSには目的に応じたデータのテンプレートが用意されているが、せっかくなので自分で定義してみる。

APIの基本情報

項目内容
API名ブログ
エンドポイントblog
APIの型リスト形式

APIのスキーマ

とりあえず使いそうな最低限の定義を作成。あわせていくつか適当に記事を登録しておく。

フィールドID表示名種類必須
titleタイトルテキストフィールド必須
body本文リッチエディタ必須
date日付日時必須
isPinned固定表示真偽値必須

プロジェクトの作成

/some/directory
npx create-next-app@latest .

個人的にはユーティリティは最小限にとどめたい感覚がある(「回復」と「全体に適用」を組み合わせて使うぐらいなら、「全体回復」が欲しくなる)が、知見を得るためここではTailwind CSSを使ってみる。

/some/directory
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like your code inside a `src/` directory? Yes
Would you like to use App Router? (recommended) … Yes
Would you like to use Turbopack for `next dev`? … No
Would you like to customize the import alias (`@/*` by default)? … No

開発サーバ起動。

/some/directory
npm run dev
# http://localhost:3000/

開発環境の整備

モジュールの追加

/some/directory
npm i date-fns microcms-js-sdk jotai

このあたりはもうちょっと整理してあとから追記する。

実装

SDK

.env
MICROCMS_API_KEY=ダッシュボードからコピペ
MICROCMS_SERVICE_DOMAIN=ダッシュボードからコピペ
src/libs/microcms.ts
import { createClient } from 'microcms-js-sdk';
if (!process.env.MICROCMS_SERVICE_DOMAIN) {
throw new Error('MICROCMS_SERVICE_DOMAIN is required');
}
if (!process.env.MICROCMS_API_KEY) {
throw new Error('MICROCMS_API_KEY is required');
}
export const client = createClient({
serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN,
apiKey: process.env.MICROCMS_API_KEY,
});

ブログの型

ダッシュボードで用意した内容に沿う。idは自動で付与されている。

src/types/Blog.ts
export interface Blog {
id: string;
title: string;
body: string;
date: string;
isPinned: boolean;
};

ブログの設定

定数で用意。

src/constants/blogConfig.ts
export const blogTitle = 'namakuralog';
export const blogDescription = '見た聞い読んだを個人的にまとめておくブログ。';
export const itemCountPerPage = 2; // 1ページの表示件数

データの管理

jotaiで管理してみる。

src/atoms/blogDataAtom.ts
import { atom } from 'jotai';
import type { Blog } from '@/types/Blog';
export const blogDataAtom = atom<Blog[] | null>(null);

レイアウト

ここでmicroCMSからデータを取得している。jotaiにそのデータを入れる部分は、クライアントコンポーネントに分離している。

src/app/layout.tsx
import type { Metadata } from 'next';
import { compareDesc } from 'date-fns';
import LayoutState from '@/components/LayoutState';
import { blogTitle, blogDescription } from '@/constants/blogConfig';
import { client } from '@/libs/microcms';
import type { Blog } from '@/types/Blog'
async function getBlogData(): Promise<Blog[]> {
// microCMSから全データを取得
const data = await client.get({
endpoint: 'blog',
queries: {
fields: 'id,title,body,date,isPinned'
},
});
// 日付順にソート
data.contents.sort((a:Blog, b:Blog) =>
compareDesc(new Date(a.date), new Date(b.date))
);
// 固定表示を優先
data.contents.sort((a: Blog, b: Blog) => {
if (a.isPinned && !b.isPinned) {
return -1;
} else if (!a.isPinned && b.isPinned) {
return 1;
} else {
return 0;
}
});
return data.contents;
}
export const metadata: Metadata = {
title: blogTitle,
description: blogDescription,
};
export default async function RootLayout({
children
}: Readonly<{children: React.ReactNode;}>) {
const blogData = await getBlogData();
return (
<html lang="ja">
<body>
<LayoutState data={blogData}>
{children}
</LayoutState>
</body>
</html>
);
}
src/components/LayoutState.tsx
'use client';
import { useSetAtom } from 'jotai';
import { blogDataAtom } from '@/atoms/blogDataAtom';
import type { Blog } from '@/types/Blog';
interface Props {
data: Blog[];
children: React.ReactNode;
}
export default function LayoutState({data, children}: Props) {
const setData = useSetAtom(blogDataAtom);
setData(data);
return (
<>
{children}
</>
);
}

トップページ

トップと言っても実態は一覧。

src/app/page.tsx
'use client';
import { useAtom } from 'jotai';
import { blogDataAtom } from '@/atoms/blogDataAtom';
import { itemCountPerPage } from '@/constants/blogConfig';
import ItemList from '@/components/ItemList';
export default function Home() {
// jotaiで管理しているブログの全データを取得
const [blogData] = useAtom(blogDataAtom);
// 1ページに表示する記事を抜粋
const targetPosts = blogData?.slice(0, itemCountPerPage);
return (
<ItemList
allPostsCount={blogData?.length ?? 0}
posts={targetPosts ?? [] }
/>
);
}

一覧ページ

ダイナミックルーティングによるパラメータはサーバコンポーネントで取得し、それをクライアントコンポーネントに渡す。

src/app/page/[page]/page.tsx
import PaginatedList from '@/components/PaginatedList';
export default async function Page({ params }: { params: Promise<{ page: string }> }) {
// ダイナミックルーティングのパラメータを取得
const { page } = await params;
return (
<>
<PaginatedList page={parseInt(page) ?? 1} />
</>
);
}
src/components/PaginatedList.tsx
'use client';
import { useAtom } from 'jotai';
import { blogDataAtom } from '@/atoms/blogDataAtom';
import { itemCountPerPage } from '@/constants/blogConfig';
import ItemList from '@/components/ItemList';
interface Props {
page: number;
};
export default function PaginatedList({ page }: Props) {
// jotaiで管理しているブログの全データを取得
const [blogData] = useAtom(blogDataAtom);
// 1ページに表示する記事を抜粋
const startIndex = (page - 1) * 2;
const targetPosts = blogData?.slice(startIndex, startIndex + itemCountPerPage);
return (
<>
<ItemList
allPostsCount={blogData?.length ?? 0}
currentPage={page}
posts={targetPosts ?? []}
/>
</>
);
}

記事ページ

やることは一覧ページと同じ。

src/app/[id]/page.tsx
import Post from '@/components/Post';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
// ダイナミックルーティングのパラメータを取得
const { id } = await params;
return (
<Post id={id} />
);
}
src/components/Post.tsx
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { useAtom } from 'jotai';
import { blogDataAtom } from '@/atoms/blogDataAtom';
import { itemCountPerPage } from '@/constants/blogConfig';
interface Props {
id: string;
};
export default function Post({ id }: Props) {
// jotaiで管理しているブログの全データを取得
const [blogData] = useAtom(blogDataAtom);
// 記事を特定
const targetPostIndex = blogData ? blogData.findIndex(post => post.id === id) : -1;
// 表示分けのため、存在フラグを用意
const existPost = blogData && targetPostIndex >= 0;
// 記事データ
const targetPost = existPost ? blogData[targetPostIndex] : null;
// 一覧ページの番号を算出
const page = Math.ceil((targetPostIndex + 1) / itemCountPerPage);
return (
<main>
{
targetPost && (
<>
<h1>
{targetPost.title}
</h1>
<div>
{format(targetPost.date, 'yyyy/MM/dd')}
</div>
<div dangerouslySetInnerHTML={{ __html: targetPost.body }}></div>
</>
)
}
{
!targetPost && (
<p>
お探しの記事はありません。
</p>
)
}
<Link href={ page === 1 ? '/' : `/page/${page}/` }>
一覧に戻る
</Link>
</main>
);
}

スタイリング

WIP

続く...かもね