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

更新:2025-03-31
JavaScript
概要
見た聞い読んだを個人的にまとめておくブログを、Next.jsとmicroCMSで作ってみる。
サンプル記事の作成
microCMSには目的に応じたデータのテンプレートが用意されているが、せっかくなので自分で定義してみる。
APIの基本情報
項目 | 内容 |
---|---|
API名 | ブログ |
エンドポイント | blog |
APIの型 | リスト形式 |
APIのスキーマ
とりあえず使いそうな最低限の定義を作成。あわせていくつか適当に記事を登録しておく。
フィールドID | 表示名 | 種類 | 必須 |
---|---|---|---|
title | タイトル | テキストフィールド | 必須 |
body | 本文 | リッチエディタ | 必須 |
date | 日付 | 日時 | 必須 |
isPinned | 固定表示 | 真偽値 | 必須 |
プロジェクトの作成
npx create-next-app@latest .
個人的にはユーティリティは最小限にとどめたい感覚がある(「回復」と「全体に適用」を組み合わせて使うぐらいなら、「全体回復」が欲しくなる)が、知見を得るためここではTailwind CSSを使ってみる。
✔ 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
開発サーバ起動。
npm run dev# http://localhost:3000/
開発環境の整備
モジュールの追加
npm i date-fns microcms-js-sdk jotai
このあたりはもうちょっと整理してあとから追記する。
実装
SDK
MICROCMS_API_KEY=ダッシュボードからコピペMICROCMS_SERVICE_DOMAIN=ダッシュボードからコピペ
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
は自動で付与されている。
export interface Blog { id: string; title: string; body: string; date: string; isPinned: boolean;};
ブログの設定
定数で用意。
export const blogTitle = 'namakuralog';export const blogDescription = '見た聞い読んだを個人的にまとめておくブログ。';export const itemCountPerPage = 2; // 1ページの表示件数
データの管理
jotai
で管理してみる。
import { atom } from 'jotai';import type { Blog } from '@/types/Blog';
export const blogDataAtom = atom<Blog[] | null>(null);
レイアウト
ここでmicroCMSからデータを取得している。jotai
にそのデータを入れる部分は、クライアントコンポーネントに分離している。
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> );}
'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} </> );}
トップページ
トップと言っても実態は一覧。
'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 ?? [] } /> );}
一覧ページ
ダイナミックルーティングによるパラメータはサーバコンポーネントで取得し、それをクライアントコンポーネントに渡す。
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} /> </> );}
'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 ?? []} /> </> );}
記事ページ
やることは一覧ページと同じ。
import Post from '@/components/Post';
export default async function Page({ params }: { params: Promise<{ id: string }> }) { // ダイナミックルーティングのパラメータを取得 const { id } = await params;
return ( <Post id={id} /> );}
'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
続く...かもね