はじめに
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 + 自動生成型 |
| SEO | generateMetadata + JSON-LD |
| RSSフィード | Route Handler + feed |
| サイトマップ | sitemap.ts |
| カスタムコンポーネント | MDXコンポーネント |
発展的な機能
この基本構成に以下の機能を追加することも可能です。
- 検索機能: Algolia、Pagefind
- コメント機能: Giscus、Disqus
- ニュースレター: ConvertKit、Buttondown
- アナリティクス: Vercel Analytics、Plausible
- 画像最適化: Cloudinary、imgix
参考文献
- Contentlayer Documentation - Contentlayerの公式ドキュメント
- Next.js App Router Documentation - Next.js App Routerの公式ドキュメント
- Next.js Metadata - メタデータAPIの詳細
- MDX - MDXの公式サイト
- Schema.org BlogPosting - 構造化データの仕様
- rehype-pretty-code - コードブロックのシンタックスハイライト
- remark-gfm - GitHub Flavored Markdownサポート