Documentation Next.js

はじめに

Next.js App Routerを使用したブログプラットフォームの構築方法を解説します。この記事では、以下の機能を実装します。

  • Contentlayer + MDX: 型安全なコンテンツ管理
  • SEO最適化: メタデータ、JSON-LD、OGP画像
  • RSSフィード: 購読者向けの配信
  • サイトマップ: 検索エンジン向け
  • タグシステム: カテゴリー分類
  • カスタムMDXコンポーネント: リッチなコンテンツ表現

プロジェクト構成

my-blog/
├── app/
│   ├── blog/
│   │   ├── page.tsx          # 記事一覧
│   │   ├── [slug]/
│   │   │   └── page.tsx      # 記事詳細
│   │   └── tags/
│   │       └── [tag]/
│   │           └── page.tsx  # タグ別一覧
│   ├── feed.xml/
│   │   └── route.ts          # RSSフィード
│   └── sitemap.ts            # サイトマップ
├── components/
│   └── mdx/                   # カスタムMDXコンポーネント
├── content/
│   └── posts/                 # MDX記事ファイル
├── contentlayer.config.ts
└── next.config.js

Contentlayerのセットアップ

Contentlayerは、MDXファイルを型安全に管理するためのライブラリです。ビルド時にコンテンツを解析し、TypeScriptの型定義を自動生成します。

インストール

npm install contentlayer next-contentlayer date-fns
npm install -D @types/mdx
npm install remark-gfm rehype-slug rehype-pretty-code

next.config.js

Next.jsの設定ファイルにContentlayerのプラグインを追加します。

// next.config.js
const { withContentlayer } = require('next-contentlayer');

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**',
      },
    ],
  },
};

module.exports = withContentlayer(nextConfig);

contentlayer.config.ts

コンテンツのスキーマを定義します。fieldsで必須・オプションのメタデータを、computedFieldsで計算フィールドを定義します。

// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import remarkGfm from 'remark-gfm';

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: 'posts/**/*.mdx',
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      required: true,
      description: '記事のタイトル',
    },
    description: {
      type: 'string',
      required: true,
      description: '記事の説明(SEO用)',
    },
    publishedAt: {
      type: 'date',
      required: true,
      description: '公開日',
    },
    updatedAt: {
      type: 'date',
      description: '更新日',
    },
    tags: {
      type: 'list',
      of: { type: 'string' },
      description: 'タグ一覧',
    },
    image: {
      type: 'string',
      description: 'アイキャッチ画像のパス',
    },
    draft: {
      type: 'boolean',
      default: false,
      description: '下書きフラグ',
    },
  },
  computedFields: {
    // URLスラッグ
    slug: {
      type: 'string',
      resolve: (post) => post._raw.flattenedPath.replace('posts/', ''),
    },
    // 完全なURL
    url: {
      type: 'string',
      resolve: (post) => `/blog/${post._raw.flattenedPath.replace('posts/', '')}`,
    },
    // 読了時間(分)
    readingTime: {
      type: 'number',
      resolve: (post) => {
        const wordsPerMinute = 500; // 日本語は文字数ベース
        const charCount = post.body.raw.length;
        return Math.ceil(charCount / wordsPerMinute);
      },
    },
    // 見出し一覧(目次用)
    headings: {
      type: 'json',
      resolve: (post) => {
        const headingRegex = /^(#{2,3})\s+(.+)$/gm;
        const headings = [];
        let match;

        while ((match = headingRegex.exec(post.body.raw)) !== null) {
          headings.push({
            level: match[1].length,
            text: match[2],
            slug: match[2]
              .toLowerCase()
              .replace(/\s+/g, '-')
              .replace(/[^\w\-\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]+/g, ''),
          });
        }

        return headings;
      },
    },
  },
}));

export default makeSource({
  contentDirPath: 'content',
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      rehypeSlug,
      [
        rehypeAutolinkHeadings,
        {
          behavior: 'wrap',
          properties: {
            className: ['heading-link'],
          },
        },
      ],
      [
        rehypePrettyCode,
        {
          theme: 'github-dark',
          onVisitLine(node: any) {
            // 空行にもスタイルを適用
            if (node.children.length === 0) {
              node.children = [{ type: 'text', value: ' ' }];
            }
          },
          onVisitHighlightedLine(node: any) {
            node.properties.className.push('highlighted');
          },
          onVisitHighlightedWord(node: any) {
            node.properties.className = ['word'];
          },
        },
      ],
    ],
  },
});

tsconfig.jsonの設定

Contentlayerの型定義を認識させるため、パスエイリアスを追加します。

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [".contentlayer/generated"]
}

MDXファイルの構造

ディレクトリ構成

content/
└── posts/
    ├── getting-started-with-nextjs/
    │   ├── index.mdx
    │   └── images/
    │       └── screenshot.png
    ├── building-a-blog/
    │   └── index.mdx
    └── seo-optimization/
        └── index.mdx

サンプルMDXファイル

---
title: Next.jsでブログを始める
description: Next.js App Routerを使ったモダンなブログ構築の入門ガイド
publishedAt: 2024-01-15
updatedAt: 2024-01-20
tags:
  - Next.js
  - React
  - TypeScript
image: /images/posts/getting-started.jpg
---

## はじめに

この記事では、Next.jsを使ったブログの構築方法を解説します。

<Callout type="info">
  この記事はNext.js 14以降のApp Routerを対象としています。
</Callout>

## 環境構築

まずはプロジェクトを作成します。

```bash
npx create-next-app@latest my-blog --typescript --tailwind --app

コード例

以下はシンプルなReactコンポーネントの例です。

interface ButtonProps {
  children: React.ReactNode;
  onClick: () => void;
}

export function Button({ children, onClick }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className="px-4 py-2 bg-blue-500 text-white rounded"
    >
      {children}
    </button>
  );
}

## カスタムMDXコンポーネント

MDX内で使用できるカスタムコンポーネントを作成します。

### Calloutコンポーネント

```tsx
// components/mdx/Callout.tsx
import { ReactNode } from 'react';

interface CalloutProps {
  type?: 'info' | 'warning' | 'error' | 'success';
  title?: string;
  children: ReactNode;
}

const styles = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  success: 'bg-green-50 border-green-200 text-green-800',
};

const icons = {
  info: '💡',
  warning: '⚠️',
  error: '❌',
  success: '✅',
};

export function Callout({ type = 'info', title, children }: CalloutProps) {
  return (
    <div className={`border-l-4 p-4 my-4 rounded-r ${styles[type]}`}>
      <div className="flex items-start gap-2">
        <span className="text-xl">{icons[type]}</span>
        <div>
          {title && <p className="font-bold mb-1">{title}</p>}
          <div className="text-sm">{children}</div>
        </div>
      </div>
    </div>
  );
}

MDXコンポーネントの登録

// components/mdx/index.tsx
import { Callout } from './Callout';
import Image from 'next/image';
import Link from 'next/link';

// カスタムコンポーネントの定義
export const mdxComponents = {
  // カスタムコンポーネント
  Callout,

  // HTML要素のオーバーライド
  img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
    <Image
      src={src || ''}
      alt={alt || ''}
      width={800}
      height={400}
      className="rounded-lg my-4"
      {...props}
    />
  ),

  a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
    const isExternal = href?.startsWith('http');

    if (isExternal) {
      return (
        <a
          href={href}
          target="_blank"
          rel="noopener noreferrer"
          className="text-blue-600 hover:underline"
          {...props}
        >
          {children}
          <span className="inline-block ml-1">↗</span>
        </a>
      );
    }

    return (
      <Link href={href || ''} className="text-blue-600 hover:underline" {...props}>
        {children}
      </Link>
    );
  },

  // コードブロック用のスタイル
  pre: ({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) => (
    <pre className="overflow-x-auto rounded-lg p-4 my-4" {...props}>
      {children}
    </pre>
  ),

  code: ({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) => {
    // インラインコード
    if (!className) {
      return (
        <code
          className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono"
          {...props}
        >
          {children}
        </code>
      );
    }
    // コードブロック内のコード
    return (
      <code className={className} {...props}>
        {children}
      </code>
    );
  },
};

ブログ記事一覧ページ

記事一覧を表示するページを作成します。ページネーションやフィルタリング機能も含めます。

// app/blog/page.tsx
import { allPosts } from 'contentlayer/generated';
import { compareDesc } from 'date-fns';
import Link from 'next/link';
import Image from 'next/image';
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'ブログ | MyBlog',
  description: '技術記事やチュートリアルを公開しています',
  openGraph: {
    title: 'ブログ | MyBlog',
    description: '技術記事やチュートリアルを公開しています',
  },
};

function formatDate(dateString: string) {
  return new Date(dateString).toLocaleDateString('ja-JP', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  });
}

export default function BlogPage() {
  // 公開済みの記事を新しい順にソート
  const posts = allPosts
    .filter((post) => !post.draft)
    .sort((a, b) => compareDesc(new Date(a.publishedAt), new Date(b.publishedAt)));

  // タグ一覧を抽出
  const allTags = Array.from(
    new Set(posts.flatMap((post) => post.tags || []))
  ).sort();

  return (
    <div className="container mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold mb-4">ブログ</h1>
      <p className="text-gray-600 mb-8">
        {posts.length}件の記事があります
      </p>

      {/* タグフィルター */}
      <div className="mb-8">
        <h2 className="text-lg font-semibold mb-3">タグで絞り込む</h2>
        <div className="flex flex-wrap gap-2">
          {allTags.map((tag) => (
            <Link
              key={tag}
              href={`/blog/tags/${encodeURIComponent(tag)}`}
              className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-full transition"
            >
              {tag}
            </Link>
          ))}
        </div>
      </div>

      {/* 記事一覧 */}
      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
        {posts.map((post) => (
          <article
            key={post.slug}
            className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition"
          >
            {post.image && (
              <Link href={post.url}>
                <div className="relative aspect-video">
                  <Image
                    src={post.image}
                    alt={post.title}
                    fill
                    className="object-cover"
                    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
                  />
                </div>
              </Link>
            )}

            <div className="p-6">
              {/* タグ */}
              <div className="flex flex-wrap gap-2 mb-3">
                {post.tags?.slice(0, 3).map((tag) => (
                  <Link
                    key={tag}
                    href={`/blog/tags/${encodeURIComponent(tag)}`}
                    className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition"
                  >
                    {tag}
                  </Link>
                ))}
              </div>

              {/* タイトル */}
              <Link href={post.url}>
                <h2 className="text-xl font-semibold mb-2 hover:text-blue-600 transition line-clamp-2">
                  {post.title}
                </h2>
              </Link>

              {/* 説明 */}
              <p className="text-gray-600 text-sm mb-4 line-clamp-2">
                {post.description}
              </p>

              {/* メタ情報 */}
              <div className="flex items-center text-sm text-gray-500">
                <time dateTime={post.publishedAt}>
                  {formatDate(post.publishedAt)}
                </time>
                <span className="mx-2">•</span>
                <span>{post.readingTime}分で読める</span>
              </div>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

記事詳細ページ

個別の記事を表示するページです。目次、関連記事、JSON-LDなどを含めます。

// app/blog/[slug]/page.tsx
import { allPosts } from 'contentlayer/generated';
import { notFound } from 'next/navigation';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { mdxComponents } from '@/components/mdx';

interface Props {
  params: Promise<{ slug: string }>;
}

// 静的パラメータの生成
export async function generateStaticParams() {
  return allPosts
    .filter((post) => !post.draft)
    .map((post) => ({
      slug: post.slug,
    }));
}

// メタデータの生成
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = allPosts.find((p) => p.slug === slug);

  if (!post) {
    return { title: 'Not Found' };
  }

  const siteUrl = 'https://myblog.com';

  return {
    title: `${post.title} | MyBlog`,
    description: post.description,
    authors: [{ name: 'Author Name' }],
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt || post.publishedAt,
      url: `${siteUrl}${post.url}`,
      images: post.image
        ? [
            {
              url: post.image.startsWith('/')
                ? `${siteUrl}${post.image}`
                : post.image,
              width: 1200,
              height: 630,
              alt: post.title,
            },
          ]
        : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      images: post.image ? [post.image] : [],
    },
    alternates: {
      canonical: `${siteUrl}${post.url}`,
    },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = allPosts.find((p) => p.slug === slug);

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

  // 関連記事(同じタグを持つ記事)
  const relatedPosts = allPosts
    .filter(
      (p) =>
        p.slug !== post.slug &&
        !p.draft &&
        p.tags?.some((tag) => post.tags?.includes(tag))
    )
    .slice(0, 3);

  return (
    <>
      <article className="container mx-auto py-12 px-4 max-w-4xl">
        {/* ヘッダー */}
        <header className="mb-8">
          {/* パンくずリスト */}
          <nav className="text-sm text-gray-500 mb-4">
            <Link href="/" className="hover:text-blue-600">
              ホーム
            </Link>
            <span className="mx-2">/</span>
            <Link href="/blog" className="hover:text-blue-600">
              ブログ
            </Link>
            <span className="mx-2">/</span>
            <span>{post.title}</span>
          </nav>

          <h1 className="text-4xl font-bold mb-4">{post.title}</h1>

          {/* メタ情報 */}
          <div className="flex flex-wrap items-center gap-4 text-gray-600 mb-4">
            <time dateTime={post.publishedAt}>
              公開: {new Date(post.publishedAt).toLocaleDateString('ja-JP')}
            </time>
            {post.updatedAt && (
              <time dateTime={post.updatedAt}>
                更新: {new Date(post.updatedAt).toLocaleDateString('ja-JP')}
              </time>
            )}
            <span>{post.readingTime}分で読める</span>
          </div>

          {/* タグ */}
          {post.tags && (
            <div className="flex flex-wrap gap-2">
              {post.tags.map((tag) => (
                <Link
                  key={tag}
                  href={`/blog/tags/${encodeURIComponent(tag)}`}
                  className="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full hover:bg-blue-200 transition"
                >
                  {tag}
                </Link>
              ))}
            </div>
          )}
        </header>

        {/* アイキャッチ画像 */}
        {post.image && (
          <div className="relative aspect-video mb-8 rounded-lg overflow-hidden">
            <Image
              src={post.image}
              alt={post.title}
              fill
              className="object-cover"
              priority
              sizes="(max-width: 896px) 100vw, 896px"
            />
          </div>
        )}

        {/* 目次 */}
        {post.headings && post.headings.length > 0 && (
          <TableOfContents headings={post.headings} />
        )}

        {/* 本文 */}
        <div className="prose prose-lg max-w-none prose-headings:scroll-mt-20">
          <MDXContent code={post.body.code} />
        </div>

        {/* 関連記事 */}
        {relatedPosts.length > 0 && (
          <section className="mt-16 pt-8 border-t">
            <h2 className="text-2xl font-bold mb-6">関連記事</h2>
            <div className="grid md:grid-cols-3 gap-6">
              {relatedPosts.map((relatedPost) => (
                <Link
                  key={relatedPost.slug}
                  href={relatedPost.url}
                  className="block p-4 border rounded-lg hover:border-blue-500 transition"
                >
                  <h3 className="font-semibold line-clamp-2 mb-2">
                    {relatedPost.title}
                  </h3>
                  <p className="text-sm text-gray-600 line-clamp-2">
                    {relatedPost.description}
                  </p>
                </Link>
              ))}
            </div>
          </section>
        )}
      </article>

      {/* 構造化データ */}
      <JsonLd post={post} />
    </>
  );
}

// MDXコンテンツのレンダリング
function MDXContent({ code }: { code: string }) {
  const Component = useMDXComponent(code);
  return <Component components={mdxComponents} />;
}

// 目次コンポーネント
function TableOfContents({
  headings,
}: {
  headings: { level: number; text: string; slug: string }[];
}) {
  return (
    <nav className="bg-gray-50 rounded-lg p-6 mb-8">
      <h2 className="text-lg font-bold mb-4">目次</h2>
      <ul className="space-y-2">
        {headings.map((heading) => (
          <li
            key={heading.slug}
            style={{ paddingLeft: `${(heading.level - 2) * 1}rem` }}
          >
            <a
              href={`#${heading.slug}`}
              className="text-gray-600 hover:text-blue-600 transition"
            >
              {heading.text}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

// JSON-LD構造化データ
function JsonLd({ post }: { post: (typeof allPosts)[0] }) {
  const siteUrl = 'https://myblog.com';

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt || post.publishedAt,
    url: `${siteUrl}${post.url}`,
    image: post.image
      ? post.image.startsWith('/')
        ? `${siteUrl}${post.image}`
        : post.image
      : undefined,
    author: {
      '@type': 'Person',
      name: 'Author Name',
      url: siteUrl,
    },
    publisher: {
      '@type': 'Organization',
      name: 'MyBlog',
      url: siteUrl,
      logo: {
        '@type': 'ImageObject',
        url: `${siteUrl}/logo.png`,
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `${siteUrl}${post.url}`,
    },
    keywords: post.tags?.join(', '),
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

RSSフィード生成

購読者向けにRSSフィードを生成します。

// app/feed.xml/route.ts
import { allPosts } from 'contentlayer/generated';
import { Feed } from 'feed';

const site = {
  title: 'MyBlog',
  description: '技術ブログ',
  url: 'https://myblog.com',
  author: 'Author Name',
  email: 'author@myblog.com',
};

export async function GET() {
  const feed = new Feed({
    title: site.title,
    description: site.description,
    id: site.url,
    link: site.url,
    language: 'ja',
    image: `${site.url}/logo.png`,
    favicon: `${site.url}/favicon.ico`,
    copyright: `All rights reserved ${new Date().getFullYear()}, ${site.author}`,
    updated: new Date(),
    feedLinks: {
      rss2: `${site.url}/feed.xml`,
      json: `${site.url}/feed.json`,
      atom: `${site.url}/atom.xml`,
    },
    author: {
      name: site.author,
      email: site.email,
      link: site.url,
    },
  });

  // 公開済みの記事を新しい順にソート
  const posts = allPosts
    .filter((post) => !post.draft)
    .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
    .slice(0, 20); // 最新20件

  posts.forEach((post) => {
    const url = `${site.url}${post.url}`;

    feed.addItem({
      title: post.title,
      id: url,
      link: url,
      description: post.description,
      content: post.body.raw.slice(0, 500) + '...',
      date: new Date(post.publishedAt),
      image: post.image
        ? post.image.startsWith('/')
          ? `${site.url}${post.image}`
          : post.image
        : undefined,
      category: post.tags?.map((tag) => ({ name: tag })),
    });
  });

  return new Response(feed.rss2(), {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  });
}

サイトマップ生成

検索エンジン向けにサイトマップを生成します。

// app/sitemap.ts
import { allPosts } from 'contentlayer/generated';
import { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = 'https://myblog.com';

  // 記事ページ
  const posts = allPosts
    .filter((post) => !post.draft)
    .map((post) => ({
      url: `${baseUrl}${post.url}`,
      lastModified: new Date(post.updatedAt || post.publishedAt),
      changeFrequency: 'weekly' as const,
      priority: 0.8,
    }));

  // タグページ
  const tags = Array.from(
    new Set(allPosts.flatMap((post) => post.tags || []))
  ).map((tag) => ({
    url: `${baseUrl}/blog/tags/${encodeURIComponent(tag)}`,
    lastModified: new Date(),
    changeFrequency: 'weekly' as const,
    priority: 0.6,
  }));

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.9,
    },
    ...posts,
    ...tags,
  ];
}

タグページ

タグ別の記事一覧ページを作成します。

// app/blog/tags/[tag]/page.tsx
import { allPosts } from 'contentlayer/generated';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { Metadata } from 'next';
import { compareDesc } from 'date-fns';

interface Props {
  params: Promise<{ tag: string }>;
}

export async function generateStaticParams() {
  const tags = new Set<string>();
  allPosts
    .filter((post) => !post.draft)
    .forEach((post) => {
      post.tags?.forEach((tag) => tags.add(tag));
    });

  return Array.from(tags).map((tag) => ({
    tag: encodeURIComponent(tag),
  }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);

  return {
    title: `${decodedTag}の記事一覧 | MyBlog`,
    description: `${decodedTag}に関する記事一覧です。`,
    openGraph: {
      title: `${decodedTag}の記事一覧 | MyBlog`,
      description: `${decodedTag}に関する記事一覧です。`,
    },
  };
}

export default async function TagPage({ params }: Props) {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);

  const posts = allPosts
    .filter((post) => !post.draft && post.tags?.includes(decodedTag))
    .sort((a, b) => compareDesc(new Date(a.publishedAt), new Date(b.publishedAt)));

  if (posts.length === 0) {
    notFound();
  }

  return (
    <div className="container mx-auto py-12 px-4 max-w-4xl">
      {/* パンくずリスト */}
      <nav className="text-sm text-gray-500 mb-4">
        <Link href="/" className="hover:text-blue-600">
          ホーム
        </Link>
        <span className="mx-2">/</span>
        <Link href="/blog" className="hover:text-blue-600">
          ブログ
        </Link>
        <span className="mx-2">/</span>
        <span>タグ: {decodedTag}</span>
      </nav>

      <h1 className="text-3xl font-bold mb-2">
        タグ: {decodedTag}
      </h1>
      <p className="text-gray-600 mb-8">{posts.length}件の記事</p>

      <div className="space-y-8">
        {posts.map((post) => (
          <article
            key={post.slug}
            className="border-b pb-8 last:border-b-0"
          >
            <Link href={post.url}>
              <h2 className="text-xl font-semibold hover:text-blue-600 transition mb-2">
                {post.title}
              </h2>
            </Link>
            <p className="text-gray-600 mb-3">{post.description}</p>
            <div className="flex items-center gap-4 text-sm text-gray-500">
              <time dateTime={post.publishedAt}>
                {new Date(post.publishedAt).toLocaleDateString('ja-JP')}
              </time>
              <span>{post.readingTime}分で読める</span>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

robots.txtの設定

// app/robots.ts
import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  const baseUrl = 'https://myblog.com';

  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/api/', '/admin/'],
    },
    sitemap: `${baseUrl}/sitemap.xml`,
  };
}

まとめ

Next.js App Routerを使ったブログプラットフォームの主要機能を実装しました。

機能実装方法
コンテンツ管理Contentlayer + MDX
型安全TypeScript + 自動生成型
SEOgenerateMetadata + JSON-LD
RSSフィードRoute Handler + feed
サイトマップsitemap.ts
カスタムコンポーネントMDXコンポーネント

発展的な機能

この基本構成に以下の機能を追加することも可能です。

  • 検索機能: Algolia、Pagefind
  • コメント機能: Giscus、Disqus
  • ニュースレター: ConvertKit、Buttondown
  • アナリティクス: Vercel Analytics、Plausible
  • 画像最適化: Cloudinary、imgix

参考文献

円