Next.jsでブログを作る #2 記事一覧と詳細ページ

読了 8分

前回はデータモデルとフォルダ構造を整え、最初のMDX記事を作りました。今回は本物の画面を描きます — ホームに記事一覧/posts/[slug]に記事詳細、2つのページを完成させるのが目標です。

記事一覧ページ #

ホーム(/)に記事一覧を描きましょう。Server Componentなのでfsモジュールをそのまま使えます。

src/app/page.js

src/app/page.js
import Link from 'next/link';
import { getAllPosts } from './lib/posts';

export default function HomePage() {
  const posts = getAllPosts();

  return (
    <main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>ブログ</h1>
      {posts.length === 0 ? (
        <p>まだ記事がありません</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {posts.map(post => (
            <li key={post.slug} style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
              <h2 style={{ margin: 0 }}>
                <Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
              </h2>
              <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
              <p>{post.frontmatter.description}</p>
              <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
                {(post.frontmatter.tags ?? []).map(tag => (
                  <span key={tag} style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px' }}>
                    {tag}
                  </span>
                ))}
              </div>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

保存してhttp://localhost:3000にアクセスすると、posts/に作っておいた記事が一覧として現れます (draftは除外)。インターネットが速くても遅くても最初の画面はすぐに見えるはずです — Server Componentがビルド/リクエスト時点で先にHTMLを作って送ってくれるからです。

コンソールがどこに出るか確認する #

前回のシリーズで強調したメンタルモデルをもう一度確認してみましょう。ファイルの先頭にconsole.logを仕込んでみてください。

実験
import Link from 'next/link';
import { getAllPosts } from './lib/posts';

export default function HomePage() {
  const posts = getAllPosts();
  console.log('記事数:', posts.length);
  // ...
}

ブラウザのコンソールではなく、devサーバーのターミナルに出ます。Server Componentがサーバーで実行されることが、もう一度はっきりしますね。

記事詳細ページ — 動的ルート #

/posts/hello-worldのようなURLが動かなければなりません。App Routerではフォルダ名を[param]にして動的ルートを表現します。

src/app/posts/[slug]/page.js

src/app/posts/[slug]/page.js
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';

export async function generateStaticParams() {
  return getAllSlugs().map(slug => ({ slug }));
}

export default async function PostPage({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);

  if (!post || post.frontmatter.draft) {
    notFound();
  }

  const { content } = await compileMDX({
    source: post.content,
  });

  return (
    <article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>{post.frontmatter.title}</h1>
      <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
      <p style={{ color: '#555' }}>{post.frontmatter.description}</p>
      <hr />
      {content}
    </article>
  );
}

主な要素を見ていきます。

paramsはPromise #

const { slug } = await params;

Next.js 15からparamsはPromiseなので、awaitを経て値を取り出さなければなりません。以前のバージョンに沿った資料ではparams.slugのように書かれているかもしれませんが、最新基準ではawait paramsが定石です。

compileMDXで本文を変換 #

const { content } = await compileMDX({
  source: post.content,
});

next-mdx-remote/rsccompileMDXがMarkdown本文の文字列をReactコンポーネントに変換してくれます。結果のcontentは実際のJSXツリーで、そのまま画面に描けます。

この変換はサーバーで起こります。ブラウザに行くのは変換されたHTMLだけで、MDXコンパイラ自体はクライアントバンドルには含まれません — Server Componentのもう1つの強みです。

notFound() — 記事がないとき #

if (!post || post.frontmatter.draft) {
  notFound();
}

notFound()はNext.jsが提供する関数で、呼び出した瞬間にページのレンダリングを中断して404画面を表示します。draftの記事に直接URLでアクセスしても404として処理されるので、意図せず公開されることはありません。

404画面をカスタマイズするにはsrc/app/not-found.jsを追加すればOKです (今はNext.jsのデフォルト画面を使用)。

generateStaticParams — ビルド時の静的生成 #

export async function generateStaticParams() {
  return getAllSlugs().map(slug => ({ slug }));
}

この関数があると、Next.jsはビルド時にすべてのスラグについてページを事前生成します。結果として、すべての記事が静的HTMLに変換されてCDNに配信されます。ランタイムでfsを再度読むことも、MDXを再コンパイルすることもないので非常に速いです。

この関数がないとページは動的にレンダリングされます — リクエストごとにfsを読み、コンパイルするので少し遅いですが、新しい記事の追加が即時に反映されます。

ブログのように記事が頻繁に変わらない場合、静的生成がほぼ常により良い選択です。記事を追加したら再ビルドすればよいので(Vercelはgit push時に自動)。

注記
静的生成と動的レンダリングの選択は一度に決めなくても大丈夫です。App Routerは2つのモードを自動でうまく決めてくれます — generateStaticParamsがあり、ページの中で動的関数(クッキー読み取り、ヘッダー読み取り、searchParams使用など)を使わなければ静的、それ以外は動的。今は「こう書けば静的」という程度に押さえておいてください。

動作確認 #

保存して以下を確認します。

  1. http://localhost:3000 — 記事一覧が公開日の降順で表示
  2. 各記事タイトルをクリック → 詳細ページに遷移
  3. Markdown本文がHTMLとしてうまくレンダリングされているか
  4. http://localhost:3000/posts/draft-not-shown — 404画面
  5. http://localhost:3000/posts/non-existent-post — 404画面
  6. ページのソースを表示(右クリック → ページのソース) — 本文がすでにHTMLで入っている (CSRなら空のdivだけだった部分)

最後の6番が印象的なところです。SEOフレンドリーで、検索エンジンがコンテンツをそのままインデキシングできます。

Markdown拡張 — remark-gfm #

基本のMarkdownだけではテーブル、チェックボックス、URL自動リンクのようなGitHubスタイルの記法は動きません。remark-gfmを追加しましょう。

インストール
npm install remark-gfm

compileMDX呼び出しにオプションを追加します。

src/app/posts/[slug]/page.js (修正)
import remarkGfm from 'remark-gfm';

// ...

const { content } = await compileMDX({
  source: post.content,
  options: {
    mdxOptions: {
      remarkPlugins: [remarkGfm],
    },
  },
});

これで記事本文に次のような表を書いてもうまくレンダリングされます。

| 名前 | 役割 |
|---|---|
| Server Component | サーバーで実行 |
| Client Component | ブラウザでも実行 |

チェックボックスも。

- [x] ステップ 1 完了
- [ ] ステップ 2 (進行中)
- [ ] ステップ 3

コードブロックのシンタックスハイライト — rehype-pretty-code #

技術ブログならコードブロックのハイライトはほぼ必須です。

インストール
npm install rehype-pretty-code shiki
src/app/posts/[slug]/page.js (修正)
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';

// ...

const { content } = await compileMDX({
  source: post.content,
  options: {
    mdxOptions: {
      remarkPlugins: [remarkGfm],
      rehypePlugins: [
        [rehypePrettyCode, { theme: 'github-light' }],
      ],
    },
  },
});

これで本文中のコードブロックに色が付きます。言語を明示(```js)するとその言語の文法に合わせてハイライトされます。

もう少し整えるなら次のような選択肢があります。

  • コードブロックの上にファイル名/キャプションを入れる (```js title="example.js")
  • 言語別アイコン表示 (このサイトのやり方、別途CSSが必要)

このシリーズではデフォルトオプションのみ使用します。実際に整えるのは好みで。

レイアウト — ヘッダー追加 #

今はすべてのページが本文だけで共通領域がありません。layout.jsにシンプルなヘッダーを置きましょう。

src/app/layout.js

src/app/layout.js
import Link from 'next/link';
import './globals.css';

export const metadata = {
  title: '私のブログ',
  description: 'Next.js で作ったブログ',
};

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <header style={{ padding: '12px 24px', background: '#222', color: '#fff', display: 'flex', gap: '16px' }}>
          <Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 'bold' }}>ホーム</Link>
          <Link href="/tags" style={{ color: '#fff', textDecoration: 'none' }}>タグ</Link>
          <Link href="/search" style={{ color: '#fff', textDecoration: 'none' }}>検索</Link>
        </header>
        {children}
      </body>
    </html>
  );
}

/tags/searchは#3で作るページです。先にリンクだけ張っておいて、クリックすると今は404が出ますね。次の記事で埋めていきます。

小さなリファクタ — PostCardコンポーネント #

ホームページの記事カード部分が次第に長くなります。別のコンポーネントに分離しましょう。

src/app/PostCard.jsx

src/app/PostCard.jsx
import Link from 'next/link';

export default function PostCard({ post }) {
  return (
    <li style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
      <h2 style={{ margin: 0 }}>
        <Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
      </h2>
      <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
      <p>{post.frontmatter.description}</p>
      <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
        {(post.frontmatter.tags ?? []).map(tag => (
          <Link
            key={tag}
            href={`/tags/${tag}`}
            style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px', textDecoration: 'none', color: '#333' }}
          >
            #{tag}
          </Link>
        ))}
      </div>
    </li>
  );
}

ホームページが短くなります。

src/app/page.js (整理後)
import { getAllPosts } from './lib/posts';
import PostCard from './PostCard';

export default function HomePage() {
  const posts = getAllPosts();

  return (
    <main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>ブログ</h1>
      {posts.length === 0 ? (
        <p>まだ記事がありません</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {posts.map(post => (
            <PostCard key={post.slug} post={post} />
          ))}
        </ul>
      )}
    </main>
  );
}

タグをクリックすると/tags/[tag]に遷移するように、先にリンクを仕込んでおきました (実際の動作は#3で)。

動作確認 — 総合 #

次を確認すれば#2は完了です。

  1. ホームページに記事一覧がうまく表示
  2. 各記事カードにタイトル/日付/要約/タグ
  3. 記事詳細ページのMarkdown本文がうまくレンダリング (コードブロックのハイライト含む)
  4. 表(remark-gfm)が動作
  5. draftの記事は一覧になく、直接アクセスすると404
  6. ヘッダーの「ホーム」リンクが常に動作 (他の2つのリンクは#3で)

おわりに #

今回の記事ではブログの2つのコアページを完成させました。

  • ホーム — Server Componentがfsで記事一覧を読んで表示
  • 記事詳細 — 動的ルート + compileMDXで本文をコンパイル
  • generateStaticParamsでビルド時の静的生成
  • notFound()で存在しない記事/draftを処理
  • remark-gfmrehype-pretty-codeプラグインを導入
  • 小さなリファクタでPostCardを分離

次の記事「Next.jsでブログを作る #3 タグと検索」では、ヘッダーに先に張っておいた/tags/searchを埋めていきます — タグ別記事まとめ、そしてURLクエリパラメータを活用した検索機能を作ってみます。

X