PR

【Next.jsブログ構築】トップページと記事ページのデザインを決定・実装する方法

Next.jsブログのトップページと記事ページのデザイン実装を解説するイラスト。ダークテーマのブログ画面とTailwind CSSのコードを視覚化したブログ用アイキャッチ画像 VPS・RentalServer
この記事は約59分で読めます。
記事内に広告が含まれています。
スポンサーリンク

これまでの Next.js ブログサイト構築シリーズをまとめたページは以下の通りです。

第12回までで、 Next.js ブログの基盤となる機能はすべて完成しました。

VPS のセットアップから始まり、 Markdown 記事の表示、動的ルーティング、 SEO・OGP 設定、そして Obsidian からバッチファイル1つで自動公開する仕組みまで、一通り動くようになっています。

しかし正直なところ、完成した直後のブログはお世辞にも見栄えの良いものではありませんでした。
記事のリストはシンプルなテキスト一覧で、ヘッダーには「ホーム」という文字だけ。
「これを公開して読者に見てもらえるか」と考えると、もう少し整えたいという気持ちが強くありました。

第13回では、実際に公開できるレベルのデザインを目指して、ページ全体の見た目を設計・実装します。

この記事では以下の内容を解説します。

  • デザインを「外側から内側へ」決める順序と、その理由
  • ヘッダー・フッターを2段構成・ダークテーマで実装する手順
  • トップページを2カラムグリッド+ページネーションに変更する手順
  • 個別記事ページにメタ情報と前後ナビゲーションを追加する手順
  • 静的サイトで画像を正しく表示するための設定

記事の前提条件と対象読者

この記事は、以下の環境と進捗を前提として手順を解説しています。

  • Next.js のバージョン: Next.js 16.2 系( React 19)環境の App Router を使用しています。
  • 対象読者: シリーズ第12回までを完了し、 Obsidian からの自動デプロイが動作している方を対象としています。

デザイン決定の進め方:「外側から内側へ」

実装に入る前に、デザインを決める順序についてお伝えします。

Next.js ブログのデザインを決めるときに重要なのが、「外側から内側へ」という順序で決めることです。

例えば、記事カードのデザインを先に決めてしまうと、後からヘッダーの幅が変わったときにカードのサイズも変更が必要になる、といった連鎖的な作り直しが発生することがあります。外側の大きな枠から先に固めることで、後からの修正を最小限に抑えられます。

私が実際に進めた順序は以下の通りです。

  1. 全体レイアウト(カラム構成・サイドバーの有無)
  2. ヘッダー(段数・配色・ナビゲーション項目)
  3. フッター(配色・リンク項目)
  4. サイドバー(今回は見送り)
  5. トップページ本文(カードレイアウット・表示件数・ページ遷移)
  6. 個別記事ページ(メタ情報・ナビゲーション)

この順序で決めることで、後から作り直しが発生しにくくなります。

全体レイアウトの決定:サイドバーは「今回は作らない」

最初に決めるのは、サイドバーを設けるかどうかです。

多くのブログサイトでは「メインコンテンツ+右サイドバー」の2カラムレイアウトが一般的です。
私のブログでも将来的にはサイドバーを実装したいと考えていました。

しかし今回は「サイドバーなし」で進めることにしました。

理由は明確で、現時点ではサイドバーに表示できるコンテンツが何も存在しないからです。

サイドバーに入れたいもの現状
サイト内検索機能なし(将来実装)
シリーズまとめページへのリンクまだ作成していない
アフィリエイト画像リンク契約前
人気記事ランキングデータベース導入後(第19回以降)に実装予定

正直なところ、サイドバーを実装するかどうかはかなり迷いました。ブログらしい見た目にするには2カラムの方が良いと頭ではわかっていたのですが、実際に何を表示するかを書き出してみると、何もないことに気がつきました。空のプレースホルダーだらけのサイドバーを公開するより、コンテンツが実際に揃ったタイミングで追加する方が誠実だと感じ、今回は見送りにしました。

サイドバーの実装は今後のロードマップに組み込み、別の回として取り上げます。

ヘッダーのデザイン決定

全体レイアウトが決まったら、次はヘッダーです。

今回の Next.js ブログ( next.lifework-blog.com )は、メインブログ( lifework-blog.com )とは別のサイトです。
そのため、デザインを完全に統一するのではなく、「姉妹サイトだとわかる程度に揃えつつ、独自の雰囲気を持つ」方向で設計しました。

段数の選択

メインブログのヘッダーは「情報バー→ロゴ→ナビ」の3段構成です。
Next.js ブログはシンプルに「ロゴ+ナビ」の2段構成にしました。

配色の選択

メインブログは濃紺系の配色です。
Next.js ブログではティール(青緑)を採用しました。

「Next」という名前のフレッシュさに合っていること、メインブログとはっきり差別化できること、この2点が決め手でした。

  • ロゴエリア:ダーク背景・ティール文字
  • ナビゲーション:ティール( #0f6e56 )背景・白〜薄緑文字

ナビゲーション項目

ナビゲーションには以下の7項目を設定しました。

  • HOME(トップページ)
  • Next.js(カテゴリーページ)
  • Tech(カテゴリーページ)
  • Notes(カテゴリーページ)
  • About(このブログについて)
  • LIFEWORK Blog ↗(メインブログへの外部リンク)
  • お問い合わせ

カテゴリーページ・About・お問い合わせは今後の回で作成予定のため、現時点ではリンク先が存在しない状態です。

フッターのデザイン決定

フッターはチャコール( #3a3f4a )を採用しました。

ティールのヘッダー+チャコールのフッターで上下を締める構成は、ページ全体に落ち着いた重厚感が出ます。
フッターに入れる項目は以下の4つです。

  • 免責事項
  • アフィリエイトについて
  • お問い合わせ
  • LIFEWORK Blog(外部リンク)

トップページのデザイン決定

カードレイアウットの選択

記事カードは「2カラムグリッド(縦型)」を採用しました。

メインブログが横型リストを採用しているため、あえて違うレイアウットにすることで、同じ運営者の別サイトでもそれぞれの個性が出ます。

また将来的にサイドバーを追加したとき、3カラムではカード幅が約 170px と狭すぎますが、2カラムなら約 260px を確保できます。将来の拡張を見据えても2カラムが適切な選択でした。

LIFEWORK Blog Nextのデザインされたトップページの画像

上の画像は、デザインを変更したトップページの全体像です。
まだ記事が少ないのでさびしい状態ですが、目にやさしいダークモードを採用して、色合いも個人的には気に入ってます。

ページ遷移の選択

「もっと見る」ボタンか、ページネーション(番号付きページ送り)かで迷いましたが、ページネーションを採用しました。

理由は静的サイトとの相性にあります。

「もっと見る」ボタンは通常、クリック時にサーバーから追加データを取得します。
しかし現在のサイトは output: 'export' による静的エクスポートモードで動作しており、この方式ではページ表示後にサーバーとやり取りすることができません。

「もっと見る」を強引に実装しようとすると、全記事データを最初から隠しておいて JavaScript で表示する方法しかなく、記事が増えるにつれて初期表示が重くなっていきます。

静的サイトとの相性が良いページネーションを選ぶ方が、シンプルかつ堅実な設計です。

表示件数は1ページあたり10件(2列×5行)としました。

個別記事ページのデザイン決定

個別記事ページで新たに追加したのは以下の2つです。

メタ情報エリア(タイトル上下)

タイトルの周辺に、カテゴリーバッジ・投稿日・ステータスバッジ・タグを表示します。

ステータスバッジとは、この Next.js ブログ独自の機能で、記事の状態を3段階で表示するものです。

ステータス値表示テキスト意味
done完了完結している記事
wip進行中まだ試行中の記事
failed断念うまくいかなかった記事

「途中でも失敗でも公開する」というブログの個性を、見た目にも表現できます。

前後ナビゲーション

記事を読み終わった後の導線として、「前の記事・一覧に戻る・次の記事」の3ボタンを設置します。

関連記事の表示は第18回以降に実装予定のため、現時点ではコードにコメントアウトの状態で枠だけ確保しておきます。

実装手順

デザイン決定の全6ステップが終わったところで、実際の実装作業に入ります。
作業は以下の4フェーズに分けて進めます。

PHASE作業内容変更ファイル数
0Next.js バージョンアップ1
1ヘッダー・フッター・レイアウト更新4
2トップページ作り直し4
3個別記事ページ作り直し1

PHASE 0:Next.js をバージョンアップする

作業開始前に、 Next.js を最新安定版にアップデートします。
最新版にすることでバグ修正やセキュリティ対応が含まれ、安定した動作が期待できます。

VPS に SSH 接続した状態で、以下のコマンドを実行してください。

cd ~/example-blog
npm install next@latest eslint-config-next@latest
  • cd ~/example-blog :作業フォルダに移動するコマンドです
  • npm install next@latest :Next.js を最新版にアップデートします
  • eslint-config-next@latest :コードの書き方チェックツールも合わせて更新します

完了したら、バージョンが上がったか確認します。

cat package.json | grep '"next"'

"next": "16.2.4" のように表示されれば、アップデート成功です。
今回の作業では 16.2.1 から 16.2.4 へアップデートされました。

アップデート後に「1 moderate severity vulnerability」という英語の警告が表示される場合があります。
「中程度の深刻度の脆弱性が1件ある」という意味ですが、個人ブログのビルドツール系パッケージに含まれるものが多く、サイト訪問者への直接的な影響はほとんどありません。
案内に従って npm audit fix を実行すると、予期せずパッケージが更新されてビルドが壊れるリスクがあるため、今回は実行しないことにしました。

PHASE 1:ヘッダー・フッター・レイアウトを更新する

変更するファイルは以下の4つです。

  • ~/example-blog/components/Header.tsx
  • ~/example-blog/components/Footer.tsx
  • ~/example-blog/app/layout.tsx
  • ~/example-blog/app/globals.css

components フォルダを新規作成する

現在の example-blog フォルダの構成は以下のようになっています。

example-blog/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── posts/
│       └── [id]/
│           └── page.tsx
├── lib/
│   └── posts.ts
└── posts/
    └── (Markdown 記事)

ヘッダーとフッターは複数のページで共通して使うコンポーネント(部品)です。
こういった共通部品は、専用の components フォルダにまとめて管理するのが Next.js の一般的なルールです。

Cursor の左側のファイル一覧で example-blog フォルダを右クリックし、「新しいフォルダー」を選択して components という名前で作成してください。

作業完了後のフォルダ構成は以下のようになります。

example-blog/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── posts/
│       └── [id]/
│           └── page.tsx
├── components/         ← 今回新規作成
│   ├── Header.tsx      ← 新規作成
│   └── Footer.tsx      ← 新規作成
├── lib/
│   └── posts.ts
└── posts/
    └── (Markdown 記事)

Header.tsx を作成する

components フォルダの中に Header.tsx という新しいファイルを作成し、記事末尾の完成コードを貼り付けて保存します。

コードの構造について

ヘッダーは <header> タグの中に2つのエリアで構成されています。

<header>
  ├── ロゴエリア(1段目):サイト名・キャッチフレーズ・HOMEへのリンク
  └── ナビゲーションエリア(2段目):各ページへのリンク一覧

外部リンクの書き方について

Next.js では、サイト内のページ移動には <Link> というコンポーネントを使います。
これを使うと、ページ全体を再読み込みせずに高速に切り替わります。

しかし、メインブログのような外部サイトへのリンクには <Link> コンポーネントではなく、通常の HTML の <a> タグを使う必要があります。
外部リンクには以下の属性を必ず付けてください。

<a
  href="https://lifework-blog.com"
  target="_blank"
  rel="noopener noreferrer"
>
  LIFEWORK Blog ↗
</a>
  • target="_blank" :クリック時に新しいタブで開く設定です
  • rel="noopener noreferrer" :セキュリティ対策として必須です。noopener は開いた先のページから元のページを不正に操作されることを防ぎ、noreferrer はどこから来たかの情報(リファラー)を送らない設定です

⚠️ AI が生成したコードを使う際の注意点

今回の作業で、 AI( Claude )が出力したコードに <a タグが欠落するミスが発生しました。
Header.tsx と Footer.tsx の外部リンク部分で同じミスが起きていました。

具体的には以下のように、<a の部分がそのまま抜け落ちた状態でコードが出力されていました。

{/* ❌ 誤り:<a が欠落している */}
<li>

    href="https://lifework-blog.com"
    target="_blank"
    rel="noopener noreferrer"
  >
  LIFEWORK Blog ↗
</a>
{/* ✅ 正しい */}
<li>
  <a
    href="https://lifework-blog.com"
    target="_blank"
    rel="noopener noreferrer"
  >
    LIFEWORK Blog ↗
  </a>

AI が出力したコードをそのまま使う場合は、<a><Link> などのタグ開始部分が正しく含まれているかを目視で確認することをお勧めします。

LIFEWORK Blog Nextのデザインされたヘッダーの画像

上の画像は、完成したヘッダー部分です。
メニュー部分は、マウスオーバーすると色が変わってわかりやすくなっています。

Footer.tsx を作成する

components フォルダの中に Footer.tsx という新しいファイルを作成し、記事末尾の完成コードを貼り付けて保存します。

フッターの構成はシンプルで、4つのテキストリンク+コピーライト表示の2段組みです。

コピーライトの年は new Date().getFullYear() という記述で自動取得するため、年をまたいでも手動更新が不要です。

© {new Date().getFullYear()} LIFEWORK Blog Next

new Date() は「今日の日付データ」を取得するコードです。そこから .getFullYear() で年の部分だけを取り出します。ビルドした年が自動的に表示されるため、「毎年コードを書き換える」という手間が省けます。

layout.tsx を変更する

~/example-blog/app/layout.tsxbody タグのクラスを1箇所だけ変更します。

変更前:

<body className="bg-gray-950 text-white min-h-screen flex flex-col">

変更後(ダークテーマ版):

<body className="bg-[#1e2028] text-[#e2e4ea] min-h-screen flex flex-col">

bg-[#1e2028] は Tailwind CSS で任意の色コードを指定する書き方です。
[] の中に16進数のカラーコードを書くことで、Tailwind の標準カラーにない色も使えます。

globals.css を変更する

~/example-blog/app/globals.css からダークモードの自動追従設定を削除します。

Next.js の初期テンプレートには、OS のダークモード設定に自動で追従する CSS が含まれています。

/* ↓ この6行を削除する */
@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}

この設定が残っていると、パソコンの設定がダークモードの場合に背景が意図せず黒になってしまいます。
今回のデザインでは独自にテーマを管理するため、この6行を削除します。

完成コードは記事末尾に掲載しています。

PHASE 2:トップページを作り直す

変更するファイルは以下の通りです。

  • ~/example-blog/lib/posts.tsgetSortedPostsData 関数を拡張・getPostsByPage 関数と getAllPageNums 関数を追加)
  • ~/example-blog/app/page.tsx(全面書き直し・静的パス方式に変更)
  • ~/example-blog/app/page/[pageNum]/page.tsx(新規作成・2ページ目以降を担当)
  • ~/example-blog/next.config.tsimages: { unoptimized: true } を追加)

lib/posts.ts を拡張する

getSortedPostsData() 関数が返すデータに、新デザインで必要な項目を追加します。

これまでこの関数は idtitledate の3項目しか返していませんでした。
カテゴリーバッジやステータスバッジを表示するために、以下の項目を追加します。

追加する項目用途
categoryカテゴリーバッジの表示
tags記事ページのタグ表示
statusステータスバッジの表示(done / wip / failed)
draft公開フラグ(true の記事は一覧から除外)

TypeScript の型定義について

TypeScript では、データの「形(型)」をあらかじめ定義しておくことで、コードの安全性が高まります。「この変数には文字列が入る」「この変数には文字列の配列が入る」ということを TypeScript に教えることで、間違った使い方をしたときにエラーとして知らせてくれます。

export type PostData = {
  id: string;          // 記事の ID(ファイル名)
  title: string;       // 記事タイトル
  date: string;        // 投稿日(文字列)
  category: string;    // カテゴリー名
  tags: string[];      // タグの配列([] は「配列」を意味する)
  status: string;      // 記事のステータス(done / wip / failed)
  description: string; // 記事の説明文
  ogImage: string;     // アイキャッチ画像のパス
};

draft 記事の除外処理

フロントマターに draft: true が設定されている記事を公開一覧から除外する処理を追加します。

// draft: true の記事は公開一覧から除外する
// フロントマターに draft が書かれていない場合は公開済みとして扱う
if (matterResult.data.draft === true) {
  return null;
}

draft: true の記事は null(何もない状態)を返し、後の .filter() という処理で取り除きます。
フロントマターに draft が書かれていない古い記事は、公開済みとして扱われます。

page.tsx を書き換える

~/example-blog/app/page.tsx を2カラムグリッド+ページネーション対応に全面的に書き直します。

ページネーションの実装方式について

静的 Next.js でページネーションを実現するには、各ページの URL をビルド時に HTML ファイルとして事前生成する「静的パス方式」が必要です。

URL に ?page=2 のようなクエリパラメータを使う方式は、静的エクスポートではサポートされていないため使用できません。

今回は1ページ目と2ページ目以降でファイルを分けて管理します。

ファイル担当する URL
app/page.tsx/(1ページ目)
app/page/[pageNum]/page.tsx/page/2/page/3…(2ページ目以降)

ページネーション用の共通処理は lib/posts.ts に追加した2つの関数が担当します。
完成コードは記事末尾に掲載しています。

next.config.ts への追加設定が必要

Next.js の <Image> コンポーネントは通常、画像を自動的に最適化(圧縮・リサイズ)してくれます。
しかし静的エクスポートモード( output: 'export' )では、この機能が使えません。

~/example-blog/next.config.ts に以下の設定を追加しないと、アイキャッチ画像が壊れた画像アイコンになってしまいます。

images: {
  unoptimized: true, // 静的エクスポートで画像を使う場合に必須
},

PHASE 3:個別記事ページを書き直す

~/example-blog/app/posts/[id]/page.tsx を全面的に書き直します。

前後ナビゲーションの仕組み

記事一覧は新着順(日付の降順)で配列に並んでいます。
「前の記事(日付が古い)」と「次の記事(日付が新しい)」は、配列の中での現在位置を起点に見つけます。

配列のイメージ(新着順):
[0] 最新記事(2026-04-17)
[1] 2番目に新しい記事(2026-04-10)
[2] 3番目に新しい記事(2026-04-05)  ← 現在ここを表示中
[3] 4番目(2026-03-30)              ← 「前の記事」(日付が古い)
const allPosts = getSortedPostsData(); // 新着順に並んだ全記事の配列
const currentIndex = allPosts.findIndex((p) => p.id === id); // 現在の記事の位置を探す

// 配列の後ろ方向(インデックスが大きい)= 日付が古い = 「前の記事」
const prevPost = currentIndex < allPosts.length - 1
  ? allPosts[currentIndex + 1]
  : null; // 最も古い記事の場合は null(ボタンを表示しない)

// 配列の前方向(インデックスが小さい)= 日付が新しい = 「次の記事」
const nextPost = currentIndex > 0
  ? allPosts[currentIndex - 1]
  : null; // 最新記事の場合は null(ボタンを表示しない)

prose-invert について

記事本文の表示には Tailwind CSS Typography プラグインの prose クラスを使っています。

<div className="p-6 prose prose-invert max-w-none">
  <ReactMarkdown>{postData.content}</ReactMarkdown>
</div>
  • prose :見出し・段落・リスト・コードブロックなどを読みやすいスタイルに自動的に整えてくれます
  • prose-invert :ダークテーマ向けの配色(白っぽい文字)に切り替えます。ライトテーマでは prose-gray を使います
  • max-w-noneprose が自動的に設けている幅の上限(約65文字分)を解除します

ダークテーマへの全面変更

実装が一通り完了してブラウザで確認してみると、白い背景が思った以上に目に刺さりました。
普段からパソコンの設定をダークモードにして作業しているので、余計にそう感じたのかもしれません。
自分が毎日見るブログなので、自分が心地よく感じる配色にしたいと思い、思い切ってダークテーマに変更しました。

変更した色の対応表は以下の通りです。

用途ライトテーマダークテーマ
ページ背景#f9fafb(gray-50)#1e2028
カード背景#ffffff#2a2d38
カードボーダー#e5e7eb(gray-200)#3a3f4a
本文テキスト#111827(gray-900)#e2e4ea
サブテキスト#9ca3af(gray-400)#6b7280
ロゴ背景#ffffff#1a1d26
ロゴ文字#0d5c52(ティール)#4dd4b0(明るいティール)

また記事本文の prose-grayprose-invert に変更しています。

LIFEWORK Blog Nextの個別記事のデザインされたページの画像

上の画像は、個別記事のページを表示したところです。
記事タイトル、アイキャッチ画像、本文と続いて表示されています。

ビルドして公開する

すべてのファイルを編集し終えたら、ビルドを実行して動作を確認します。

cd ~/example-blog
npm run build

エラーなく完了すると、以下のような出力が表示されます。

✓ Compiled successfully
✓ Finished TypeScript
✓ Generating static pages

Route (app)
├ ○ /
└ ● /posts/[id]
  └ /posts/about

問題なければ、デプロイスクリプトを実行して公開します。

./deploy.sh

ブラウザで https://next.lifework-blog.com を開き、以下の点を確認してください。

  • ヘッダー(2段構成・ティールのナビ)が表示されているか
  • トップページの記事が2カラムグリッドで表示されているか
  • アイキャッチ画像が正しく表示されているか
  • 記事をクリックしてカテゴリーバッジ・ステータス・前後ナビが表示されているか
  • フッターのリンクが正しく並んでいるか

まとめ

今回の実装で、 Next.js ブログは「動くだけ」の状態から「公開できる見た目」になりました。

実装の中で特に重要だったのは以下の点です。

  • デザインは「外側から内側へ」の順で決めると、後から作り直しが発生しにくい
  • 「サイドバー」や「関連記事」のようにコンテンツが揃っていない機能は、無理に実装せず後回しにする判断も重要
  • 静的エクスポートモードでは <Image> コンポーネントに unoptimized: true の設定が必要
  • AI が出力したコードはタグの欠落などのミスが含まれることがあるため、目視確認が大切

次回は Google アナリティクス( GA4 )と Google サーチコンソールの設定を行います。
どちらもデータは蓄積型のため、設定が遅れた分だけ過去のデータが失われます。サイトの外枠が整ったこのタイミングで、早めに設定しておきます。


完成コード一覧

記事の手順で使用したコードの完成版を掲載します。
以下のコードをそのままコピーして使用できます。

components/Header.tsx

import Link from 'next/link';

export default function Header() {
  return (
    <header>

      {/*
        ── ロゴエリア(1段目)──────────────────────────────
        サイト名とキャッチフレーズを表示する。
        クリックするとトップページ(/)へ移動する。
      */}
      <div className="bg-[#1a1d26] border-b border-[#2a2d38]">
        <div className="max-w-4xl mx-auto px-6 py-3 text-center">
          <Link href="/" className="inline-block group">
            <p className="text-2xl font-bold text-[#4dd4b0] group-hover:text-[#6ee7c7] transition-colors">
              LIFEWORK Blog Next
            </p>
            <p className="text-xs text-[#3a8070] mt-1 tracking-wide">
              次につながる何かを探す場所。
            </p>
          </Link>
        </div>
      </div>

      {/*
        ── ナビゲーションエリア(2段目)──────────────────────────────
        カテゴリーメニューとリンクを横並びで表示する。
        ※ カテゴリーページ・About・お問い合わせは今後作成予定のため、
          現時点ではリンク先が存在しない。
      */}
      <nav className="bg-[#0f6e56]">
        <div className="max-w-4xl mx-auto px-4">
          <ul className="flex flex-wrap justify-center">

            {/* HOME:トップページへのリンク */}
            <li>
              <Link
                href="/"
                className="block px-4 py-3 text-sm font-bold text-white hover:bg-[#0a5242] transition-colors"
              >
                HOME
              </Link>
            </li>

            {/* Next.js カテゴリーページ(第17回で作成予定)*/}
            <li>
              <Link
                href="/category/nextjs"
                className="block px-4 py-3 text-sm text-[#b0e8d8] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                Next.js
              </Link>
            </li>

            {/* Tech カテゴリーページ(第17回で作成予定)*/}
            <li>
              <Link
                href="/category/tech"
                className="block px-4 py-3 text-sm text-[#b0e8d8] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                Tech
              </Link>
            </li>

            {/* Notes カテゴリーページ(第17回で作成予定)*/}
            <li>
              <Link
                href="/category/notes"
                className="block px-4 py-3 text-sm text-[#b0e8d8] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                Notes
              </Link>
            </li>

            {/* About:このブログについてのページ(今後作成予定)*/}
            <li>
              <Link
                href="/about"
                className="block px-4 py-3 text-sm text-[#b0e8d8] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                About
              </Link>
            </li>

            {/*
              外部リンクには <Link> ではなく <a> タグを使う。
              target="_blank" で新しいタブで開く。
              rel="noopener noreferrer" はセキュリティ上の対策として必須。
            */}
            <li>
              <a
                href="https://lifework-blog.com"
                target="_blank"
                rel="noopener noreferrer"
                className="block px-4 py-3 text-sm text-[#80d0bc] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                LIFEWORK Blog ↗
              </a>
            </li>

            {/* お問い合わせ(今後作成予定)*/}
            <li>
              <Link
                href="/contact"
                className="block px-4 py-3 text-sm text-[#b0e8d8] hover:bg-[#0a5242] hover:text-white transition-colors"
              >
                お問い合わせ
              </Link>
            </li>

          </ul>
        </div>
      </nav>

    </header>
  );
}

components/Footer.tsx

export default function Footer() {
  return (
    <footer className="bg-[#3a3f4a] mt-16">
      <div className="max-w-4xl mx-auto px-6 py-8">

        {/*
          フッターリンクを横並びで表示する。
          divide-x でリンク間に縦の区切り線を入れる。
          ※ 各リンク先のページは今後作成予定。
        */}
        <ul className="flex flex-wrap justify-center divide-x divide-[#5a6070] mb-4">

          <li>
            <a
              href="/disclaimer"
              className="block px-4 py-1 text-sm text-[#b0c0d4] hover:text-white transition-colors"
            >
              免責事項
            </a>
          </li>

          <li>
            <a
              href="/affiliate"
              className="block px-4 py-1 text-sm text-[#b0c0d4] hover:text-white transition-colors"
            >
              アフィリエイトについて
            </a>
          </li>

          <li>
            <a
              href="/contact"
              className="block px-4 py-1 text-sm text-[#b0c0d4] hover:text-white transition-colors"
            >
              お問い合わせ
            </a>
          </li>

          <li>
            <a
              href="https://lifework-blog.com"
              target="_blank"
              rel="noopener noreferrer"
              className="block px-4 py-1 text-sm text-[#b0c0d4] hover:text-white transition-colors"
            >
              LIFEWORK Blog ↗
            </a>
          </li>

        </ul>

        {/* new Date().getFullYear() でビルド時の年を自動取得する */}
        <p className="text-center text-sm text-[#6a7080]">
          © {new Date().getFullYear()} LIFEWORK Blog Next
        </p>

      </div>
    </footer>
  );
}

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // 静的ファイルとして書き出す設定
  output: 'export',

  // 静的エクスポートモードでは Next.js の画像最適化機能が使えないため、
  // unoptimized: true を設定して通常の <img> タグと同じ動作にする。
  // この設定がないとアイキャッチ画像が表示されない。
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

app/globals.css

@import "tailwindcss";
@plugin "@tailwindcss/typography";

/* ダークテーマの色を CSS 変数として定義する */
:root {
  --background: #1e2028;
  --foreground: #e2e4ea;
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: Arial, Helvetica, sans-serif;
}

lib/posts.ts

import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

// posts フォルダの場所を指定する
// process.cwd() はプロジェクトのルートフォルダを意味する
const postsDirectory = path.join(process.cwd(), 'posts');

// 1記事分のデータの型定義(TypeScript に「このデータはこういう形」と教える設計図)
export type PostData = {
  id: string;
  title: string;
  date: string;
  category: string;
  tags: string[];
  status: string;
  description: string;
  ogImage: string;
};

export function getSortedPostsData(): PostData[] {
  // /posts フォルダ内の .md ファイルだけを取得(他のファイルが混ざっても安全)
  const fileNames = fs
    .readdirSync(postsDirectory)
    .filter((name) => name.endsWith('.md'));

  const allPostsData = fileNames
    .map((fileName) => {
      // ファイル名から '.md' を削除して、記事の固有 ID にする
      const id = fileName.replace(/\.md$/, '');

      // マークダウンファイルを読み込む
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, 'utf8');

      // gray-matter でフロントマター(タイトル・日付など)と本文を切り分ける
      const matterResult = matter(fileContents);

      // draft: true の記事は公開一覧から除外する
      // フロントマターに draft が書かれていない場合は公開済みとして扱う
      if (matterResult.data.draft === true) {
        return null;
      }

      // Date オブジェクトを文字列(YYYY-MM-DD)に変換する
      // React に渡す値は文字列でなければならないため
      let dateString = matterResult.data.date;
      if (dateString instanceof Date) {
        dateString = dateString.toISOString().slice(0, 10);
      } else {
        dateString = String(dateString ?? '');
      }

      // tags は配列で返す。フロントマターに書かれていない場合は空配列にする
      const tags = Array.isArray(matterResult.data.tags)
        ? matterResult.data.tags
        : [];

      return {
        id,
        title: (matterResult.data.title as string) ?? '',
        date: dateString,
        category: (matterResult.data.category as string) ?? '',
        tags,
        status: (matterResult.data.status as string) ?? 'done',
        description: (matterResult.data.description as string) ?? '',
        ogImage: (matterResult.data.ogImage as string) ?? '',
      };
    })
    // null(draft 記事)を除外する
    .filter((post): post is PostData => post !== null);

  // 日付の新しい順(降順)に並び替える
  return allPostsData.sort((a, b) => {
    if (a.date < b.date) return 1;
    if (a.date > b.date) return -1;
    return 0;
  });
}

// 指定された ID(ファイル名)の記事データを取得する関数
export function getPostData(id: string) {
  const fullPath = path.join(postsDirectory, `${id}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const matterResult = matter(fileContents);

  let dateString = matterResult.data.date;
  if (dateString instanceof Date) {
    dateString = dateString.toISOString().slice(0, 10);
  } else {
    dateString = String(dateString);
  }

  return {
    id,
    content: matterResult.content,
    title: matterResult.data.title as string,
    date: dateString,
    description: (matterResult.data.description as string) ?? '',
    ogImage: (matterResult.data.ogImage as string) ?? '',
    status: (matterResult.data.status as string) ?? 'done',
    category: (matterResult.data.category as string) ?? '',
    tags: Array.isArray(matterResult.data.tags) ? matterResult.data.tags : [],
  };
}

// ページネーション用:指定ページの記事一覧と総ページ数を返す関数
export const POSTS_PER_PAGE = 10;

export function getPostsByPage(pageNum: number): {
  posts: PostData[];
  totalPages: number;
  currentPage: number;
} {
  const allPosts = getSortedPostsData();
  const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
  const startIndex = (pageNum - 1) * POSTS_PER_PAGE;
  const posts = allPosts.slice(startIndex, startIndex + POSTS_PER_PAGE);
  return { posts, totalPages, currentPage: pageNum };
}

// 静的エクスポート用:存在する全ページ番号のリストを返す関数
export function getAllPageNums(): number[] {
  const allPosts = getSortedPostsData();
  const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
  return Array.from({ length: totalPages }, (_, i) => i + 1);
}

app/page.tsx

import Link from 'next/link';
import Image from 'next/image';
import { getPostsByPage } from '../lib/posts';

export default function Home() {
  const { posts, totalPages, currentPage } = getPostsByPage(1);

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">

      <h1 className="text-xl font-bold text-[#9ca3af] mb-6 pb-2 border-b-2 border-[#0f6e56]">
        新着記事
      </h1>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
        {posts.map((post) => (
          <article
            key={post.id}
            className="bg-[#2a2d38] rounded-lg border border-[#3a3f4a] overflow-hidden hover:border-[#4a5060] transition-colors"
          >
            <Link href={`/posts/${post.id}`} className="block">

              <div className="relative w-full aspect-[1200/630] bg-[#1e2028]">
                {post.ogImage && post.ogImage !== '' ? (
                  <Image
                    src={post.ogImage}
                    alt={post.title}
                    fill
                    className="object-cover"
                    sizes="(max-width: 768px) 100vw, 50vw"
                  />
                ) : (
                  <div className="absolute inset-0 flex items-center justify-center bg-[#252830]">
                    <span className="text-[#4a5060] text-sm">No Image</span>
                  </div>
                )}

                {post.category && (
                  <span className="absolute top-2 left-2 bg-[#0f6e56] text-white text-xs px-2 py-0.5 rounded">
                    {post.category}
                  </span>
                )}
              </div>

              <div className="p-4">
                <h2 className="text-sm font-bold text-[#e2e4ea] leading-relaxed line-clamp-3">
                  {post.title}
                </h2>
                <p className="text-xs text-[#6b7280] mt-2 text-right">
                  {post.date}
                </p>
              </div>

            </Link>
          </article>
        ))}
      </div>

      {totalPages > 1 && (
        <nav className="flex justify-center items-center gap-2 mb-12">

          {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
            const isFirst = page === 1;
            const isLast = page === totalPages;
            const isNearCurrent = Math.abs(page - currentPage) <= 1;

            if (!isFirst && !isLast && !isNearCurrent) {
              if (page === currentPage - 2 || page === currentPage + 2) {
                return (
                  <span key={page} className="w-10 h-10 flex items-center justify-center text-[#6b7280] text-sm">
                    ···
                  </span>
                );
              }
              return null;
            }

            return (
              <Link
                key={page}
                href={page === 1 ? '/' : `/page/${page}`}
                className={`w-10 h-10 flex items-center justify-center rounded-full text-sm transition-colors ${
                  page === currentPage
                    ? 'bg-[#0f6e56] text-white font-bold'
                    : 'border border-[#3a3f4a] text-[#9ca3af] hover:bg-[#2a2d38]'
                }`}
              >
                {page}
              </Link>
            );
          })}

          {currentPage < totalPages && (
            <Link
              href={`/page/${currentPage + 1}`}
              className="w-10 h-10 flex items-center justify-center rounded-full border border-[#3a3f4a] text-[#9ca3af] hover:bg-[#2a2d38] transition-colors text-sm"
            >
              ›
            </Link>
          )}

        </nav>
      )}

    </div>
  );
}

app/page/[pageNum]/page.tsx

import Link from 'next/link';
import Image from 'next/image';
import { getPostsByPage, getAllPageNums } from '../../../lib/posts';
import { notFound } from 'next/navigation';

// 静的エクスポート用:存在する全ページ番号の HTML を事前生成する
// 1ページ目(/)は app/page.tsx が担当するため、2ページ目以降のみ対象
export function generateStaticParams() {
  const allPageNums = getAllPageNums();
  const filtered = allPageNums
    .filter((pageNum) => pageNum >= 2)
    .map((pageNum) => ({
      pageNum: String(pageNum),
    }));

  // 記事が 10 件以下のときは 2 ページ目以降が存在しない。
  // generateStaticParams() が空配列を返すと静的エクスポートでエラーになるため、
  // ダミーとして pageNum: '2' を返しておく。
  // /page/2 にアクセスしても notFound() が返るため実害はない。
  if (filtered.length === 0) {
    return [{ pageNum: '2' }];
  }

  return filtered;
}

export default async function Page({
  params,
}: {
  params: Promise<{ pageNum: string }>;
}) {
  const { pageNum } = await params;
  const currentPage = Number(pageNum);

  // 数値に変換できない場合や 2 未満の場合は 404 を表示する
  // (1ページ目は / が担当するため /page/1 は存在しない)
  if (isNaN(currentPage) || currentPage < 2) {
    notFound();
  }

  const { posts, totalPages } = getPostsByPage(currentPage);

  // 存在しないページ番号にアクセスした場合も 404 を表示する
  if (currentPage > totalPages) {
    notFound();
  }

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">

      <h1 className="text-xl font-bold text-[#9ca3af] mb-6 pb-2 border-b-2 border-[#0f6e56]">
        新着記事
      </h1>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
        {posts.map((post) => (
          <article
            key={post.id}
            className="bg-[#2a2d38] rounded-lg border border-[#3a3f4a] overflow-hidden hover:border-[#4a5060] transition-colors"
          >
            <Link href={`/posts/${post.id}`} className="block">

              <div className="relative w-full aspect-[1200/630] bg-[#1e2028]">
                {post.ogImage && post.ogImage !== '' ? (
                  <Image
                    src={post.ogImage}
                    alt={post.title}
                    fill
                    className="object-cover"
                    sizes="(max-width: 768px) 100vw, 50vw"
                  />
                ) : (
                  <div className="absolute inset-0 flex items-center justify-center bg-[#252830]">
                    <span className="text-[#4a5060] text-sm">No Image</span>
                  </div>
                )}

                {post.category && (
                  <span className="absolute top-2 left-2 bg-[#0f6e56] text-white text-xs px-2 py-0.5 rounded">
                    {post.category}
                  </span>
                )}
              </div>

              <div className="p-4">
                <h2 className="text-sm font-bold text-[#e2e4ea] leading-relaxed line-clamp-3">
                  {post.title}
                </h2>
                <p className="text-xs text-[#6b7280] mt-2 text-right">
                  {post.date}
                </p>
              </div>

            </Link>
          </article>
        ))}
      </div>

      {/* ページネーション */}
      <nav className="flex justify-center items-center gap-2 mb-12">

        {/* 前のページへのリンク */}
        <Link
          href={currentPage === 2 ? '/' : `/page/${currentPage - 1}`}
          className="w-10 h-10 flex items-center justify-center rounded-full border border-[#3a3f4a] text-[#9ca3af] hover:bg-[#2a2d38] transition-colors text-sm"
        >
          ‹
        </Link>

        {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
          const isFirst = page === 1;
          const isLast = page === totalPages;
          const isNearCurrent = Math.abs(page - currentPage) <= 1;

          if (!isFirst && !isLast && !isNearCurrent) {
            if (page === currentPage - 2 || page === currentPage + 2) {
              return (
                <span key={page} className="w-10 h-10 flex items-center justify-center text-[#6b7280] text-sm">
                  ···
                </span>
              );
            }
            return null;
          }

          return (
            <Link
              key={page}
              href={page === 1 ? '/' : `/page/${page}`}
              className={`w-10 h-10 flex items-center justify-center rounded-full text-sm transition-colors ${
                page === currentPage
                  ? 'bg-[#0f6e56] text-white font-bold'
                  : 'border border-[#3a3f4a] text-[#9ca3af] hover:bg-[#2a2d38]'
              }`}
            >
              {page}
            </Link>
          );
        })}

        {/* 次のページへのリンク */}
        {currentPage < totalPages && (
          <Link
            href={`/page/${currentPage + 1}`}
            className="w-10 h-10 flex items-center justify-center rounded-full border border-[#3a3f4a] text-[#9ca3af] hover:bg-[#2a2d38] transition-colors text-sm"
          >
            ›
          </Link>
        )}

      </nav>

    </div>
  );
}

app/posts/[id]/page.tsx

import { getPostData, getSortedPostsData } from '../../../lib/posts';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import Image from 'next/image';
import Link from 'next/link';
import type { Metadata } from 'next';

// 静的書き出し用:Next.js に「このページが存在する」と教える関数
export function generateStaticParams() {
  const posts = getSortedPostsData();
  return posts.map((post) => ({
    id: post.id,
  }));
}

// ▼ ご自身のドメインに書き換えてください
const SITE_URL = 'https://next.example.com';
const DEFAULT_OG_IMAGE = '/images/default-ogp.webp';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const postData = getPostData(id);
  const ogImage = postData.ogImage || DEFAULT_OG_IMAGE;

  return {
    title: postData.title,
    description: postData.description,
    openGraph: {
      title: postData.title,
      description: postData.description,
      url: `${SITE_URL}/posts/${id}`,
      type: 'article',
      images: [{ url: `${SITE_URL}${ogImage}`, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: postData.title,
      description: postData.description,
      images: [`${SITE_URL}${ogImage}`],
    },
  };
}

// ステータスバッジの表示内容と色を定義する(ダークテーマ対応)
const statusConfig: Record<string, { label: string; className: string }> = {
  done:   { label: '完了',   className: 'bg-emerald-900 text-emerald-300' },
  wip:    { label: '進行中', className: 'bg-amber-900 text-amber-300' },
  failed: { label: '断念',   className: 'bg-red-900 text-red-300' },
};

export default async function Post({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const postData = getPostData(id);

  // 全記事データを取得して前後の記事を探す
  const allPosts = getSortedPostsData();
  const currentIndex = allPosts.findIndex((p) => p.id === id);

  // 新着順配列なので:後ろ = 日付が古い = 「前の記事」
  const prevPost = currentIndex < allPosts.length - 1
    ? allPosts[currentIndex + 1]
    : null;

  // 前 = 日付が新しい = 「次の記事」
  const nextPost = currentIndex > 0
    ? allPosts[currentIndex - 1]
    : null;

  const statusInfo = postData.status ? statusConfig[postData.status] : null;
  const tags = postData.tags ?? [];

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <article className="bg-[#2a2d38] rounded-lg border border-[#3a3f4a] overflow-hidden">

        {/* 記事ヘッダー */}
        <div className="p-6 pb-0">

          {/* カテゴリーバッジ */}
          {postData.category && (
            <span className="inline-block bg-[#0f6e56] text-white text-xs px-3 py-1 rounded mb-3">
              {postData.category}
            </span>
          )}

          {/* 記事タイトル */}
          <h1 className="text-2xl font-bold text-[#e2e4ea] leading-relaxed mb-4">
            {postData.title}
          </h1>

          {/* 日付・ステータス・タグ */}
          <div className="flex flex-wrap items-center gap-2 pb-4 border-b border-[#3a3f4a]">

            <span className="text-sm text-[#6b7280]">{postData.date}</span>

            {statusInfo && (
              <span className={`text-xs px-2 py-0.5 rounded ${statusInfo.className}`}>
                {statusInfo.label}
              </span>
            )}

            {tags.map((tag: string) => (
              <span
                key={tag}
                className="text-xs bg-[#3a3f4a] text-[#9ca3af] px-2 py-0.5 rounded border border-[#4a5060]"
              >
                {tag}
              </span>
            ))}

          </div>
        </div>

        {/* アイキャッチ画像 */}
        {postData.ogImage && postData.ogImage !== '' && (
          <div className="relative w-full aspect-[1200/630]">
            <Image
              src={postData.ogImage}
              alt={postData.title}
              fill
              className="object-cover"
              priority
            />
          </div>
        )}

        {/*
          記事本文
          prose-invert:ダークテーマ向けの配色に切り替える
          max-w-none:prose のデフォルト幅制限を解除する
        */}
        <div className="p-6 prose prose-invert max-w-none">
          <ReactMarkdown rehypePlugins={[rehypeRaw]}>
            {postData.content}
          </ReactMarkdown>
        </div>

      </article>

      {/* 前後記事ナビゲーション */}
      <nav className="mt-8 grid grid-cols-3 gap-3">

        {prevPost ? (
          <Link
            href={`/posts/${prevPost.id}`}
            className="col-span-1 bg-[#2a2d38] border border-[#3a3f4a] rounded-lg p-4 hover:border-[#4a5060] transition-colors"
          >
            <p className="text-xs text-[#6b7280] mb-1">← 前の記事</p>
            <p className="text-sm font-medium text-[#d4d8e2] line-clamp-2 leading-relaxed">
              {prevPost.title}
            </p>
          </Link>
        ) : (
          <div className="col-span-1" />
        )}

        <Link
          href="/"
          className="col-span-1 bg-[#0f6e56] text-white rounded-lg p-4 flex items-center justify-center hover:bg-[#0a5242] transition-colors"
        >
          <span className="text-sm font-medium">一覧に戻る</span>
        </Link>

        {nextPost ? (
          <Link
            href={`/posts/${nextPost.id}`}
            className="col-span-1 bg-[#2a2d38] border border-[#3a3f4a] rounded-lg p-4 hover:border-[#4a5060] transition-colors text-right"
          >
            <p className="text-xs text-[#6b7280] mb-1">次の記事 →</p>
            <p className="text-sm font-medium text-[#d4d8e2] line-clamp-2 leading-relaxed">
              {nextPost.title}
            </p>
          </Link>
        ) : (
          <div className="col-span-1" />
        )}

      </nav>

    </div>
  );
}

コメント

タイトルとURLをコピーしました