Next.jsでブログやドキュメントサイトを構築するとき、避けて通れないのが「Markdownファイルをどう管理・変換するか」という問題だ。
ヘッドレスCMSを使うほどではないが、fs.readFileSyncでゴリゴリ読み込むのも辛い。frontmatterのパースやMDXの処理、型定義の手書き——こうした”コンテンツ周りの配管工事”に時間を取られた経験があるエンジニアは少なくないだろう。
Veliteは、この課題をビルド時のスキーマ駆動で解決するコンテンツフレームワークだ。かつてContentlayerが担っていた領域をカバーしつつ、よりシンプルな設計で実用的な選択肢になっている。
Veliteとは何か
Veliteは、ローカルのMarkdown/MDX/YAMLファイルをビルド時に型安全なJSONデータへ変換するツールだ。Zodベースのスキーマでコンテンツの構造を定義し、ビルド時にバリデーションと変換を一括で行う。
似た役割を持つContentlayerは2023年後半から開発が停滞し、Next.js 14以降との互換性に問題を抱えている。Veliteはその代替として2024年に登場し、積極的にメンテナンスされている。
両者の位置づけを整理する。
| 観点 | Contentlayer | Velite |
|---|---|---|
| メンテナンス状況 | 停滞(2023年後半〜) | 活発 |
| Next.js 15対応 | 公式は停滞、コミュニティフォーク(contentlayer2)で対応 | 公式対応 |
| スキーマ定義 | 独自DSL | Zod |
| 設定ファイル | contentlayer.config.ts | velite.config.ts |
| 出力先 | .contentlayer/generated | .velite |
| MDXサポート | あり | あり |
Zodでスキーマを書くという設計判断が重要で、既存のZodの知識がそのまま使え、フォームバリデーションなど他の用途で定義した型との一貫性も保てる。
Veliteの仕組み
ビルドパイプライン
Veliteの処理は明確な3ステップで構成される。まずコンテンツファイルを読み取り、次にスキーマに従ってバリデーション・変換を行い、最後に型付きJSONとTypeScript型定義を出力する。この流れを図にすると以下のようになる。
ビルド結果は.veliteディレクトリに格納され、通常のESモジュールとしてimportできる。CMSのAPIを叩くわけではないため、ビルド時にすべてが確定し、ランタイムのオーバーヘッドはゼロだ。
Next.jsプロジェクトへの導入
インストール
npm install velite -D
Veliteはdevdependencyとして入れる。ビルド時にのみ動作するため、本番バンドルには含まれない。
next.config.tsにVeliteのwebpackプラグインを組み込む。
// next.config.ts
import { build } from 'velite'
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.plugins.push(new VeliteWebpackPlugin())
return config
},
}
class VeliteWebpackPlugin {
static started = false
apply(/** @type {import('webpack').Compiler} */ compiler) {
compiler.hooks.beforeCompile.tapPromise('VeliteWebpackPlugin', async () => {
if (VeliteWebpackPlugin.started) return
VeliteWebpackPlugin.started = true
const dev = compiler.options.mode === 'development'
await build({ watch: dev, clean: !dev })
})
}
}
export default nextConfig
この設定により、next dev実行時にはVeliteがwatchモードで起動し、コンテンツファイルの変更を検知してホットリロードが効く。next build時にはクリーンビルドが走る。
tsconfigへのパスエイリアス追加
.veliteディレクトリからの型解決を通すため、tsconfig.jsonにパスを追加する。
{
"compilerOptions": {
"paths": {
"#site/content": ["./.velite"]
}
}
}
コンテンツファイルはどう書くか
まずはどのように出力するかのスキーマを定義する
Veliteのスキーマは、コンテンツの「型」をコードとして表現する。frontmatterのフィールド、本文の変換方法、スラッグの生成ルールなどを宣言的に定義する。
// velite.config.ts
import { defineConfig, defineCollection, s } from 'velite'
const posts = defineCollection({
name: 'Post',
pattern: 'posts/**/*.mdx',
schema: s.object({
title: s.string().max(120),
slug: s.slug('posts'),
date: s.isodate(),
description: s.string().max(260),
draft: s.boolean().default(false),
tags: s.array(s.string()).default([]),
body: s.mdx(),
}),
})
export default defineConfig({
collections: { posts },
})defineCollectionは「同じ種類のコンテンツのまとまり」を定義する関数だ。ブログ記事ならposts、書籍ならbooksというように、コンテンツの種類ごとに1つ作る。patternでどのファイルをこのコレクションに含めるかをglobで指定し、schemaでそのファイルの構造を定義する。
sはVelite独自のスキーマビルダーで、Zodを拡張してコンテンツ特有のヘルパーを追加したものだ。s.slug()はファイルパスからスラッグを自動生成し、s.mdx()は本文をコンパイル済みMDXコードに変換する。s.isodate()は日付文字列をISO形式でバリデーションする。
これらはすべてZod互換なので、.max()や.default()といったおなじみのメソッドがそのまま使える。
Veliteのスキーマを決めたら、ここまでで設定側の準備は整った。あとは実際に記事を書いていくだけだ。このセクションではMarkdown/MDXファイルの書き方そのものを扱う。ブラウザでどんな見た目になるか(レイアウトやCSS)は次の「コンテンツの利用」で扱うので、ここではファイルの中身だけに集中する。
---
title: "はじめての記事"
date: "2025-01-15"
description: "Veliteで書く最初の記事です"
---
# 本文のタイトル
ここに記事の内容を書きます。**強調**や[リンク](https://example.com)も
通常のMarkdownと同じように使えます。ファイルの基本構造
MDXファイルは---で囲まれたfrontmatterと本文の2ブロックで構成される。frontmatterは記事のメタデータ、本文は記事の中身だ。
この2ブロック構造はVelite特有のものではなく、一般的な静的サイトジェネレータやブログツールで広く使われている記法だ。
frontmatterに何を書くか
frontmatterに書くのは記事のメタデータ——タイトル、日付、タグなど、本文とは別に管理したい情報だ。
ここで書けるキーは、前セクションで定義したvelite.config.tsのスキーマで決まる。
たとえばスキーマ側でtitle: s.string()と宣言していれば、frontmatterにtitleを書かないとビルドが通らない。スキーマに無いキーをfrontmatterに書いても単に無視される。
この対応関係があるおかげで、frontmatterのtypoや型違反はビルド時に検出される。本番デプロイ後に「日付フィールドを書き忘れていた」といった事故が起きない。
本文に何を書くか
本文には通常のMarkdown記法がそのまま使える。見出し、リスト、リンク、画像、コードブロックなど、普段の記法で書けばよい。
## セクション見出し
段落のテキスト。**強調**や`インラインコード`も使える。
- リスト項目1
- リスト項目2
\```tsx
// コードブロックも書ける
const hello = "world"
\```MDXの場合はこれに加えて、本文中にReactコンポーネントを埋め込める点が通常のMarkdownと異なる。コンポーネントをどう埋め込み、どう描画するかは「コンテンツの利用」で扱う。
ここで押さえておきたいのは、本文として書いたMarkdownが最終的にどんな見た目でブラウザに表示されるかは、このファイル自体では決まらないということだ。Veliteは本文を「HTMLに変換可能なデータ」として出力するだけで、それをどのReactコンポーネントで受けて、どんなCSSで装飾するかはページ側の仕事になる。
ファイルをどこに置くか
ファイルはスキーマのpatternで指定した場所に配置する。前セクションの例ではpattern: 'posts/**/*.mdx'としたので、content/posts/配下のmdxファイルが拾われる。
content/
└── posts/
├── hello-world.mdx
├── velite-intro.mdx
└── nextjs-tips.mdxファイル名はスラッグ(URLの一部)として使われる。hello-world.mdxなら/posts/hello-worldのようなパスで公開する形になる。これはスキーマでs.slug('posts')と指定しているためで、ファイル名をベースに自動生成される。
記事を新しく書きたいときは、このディレクトリに新しい.mdxファイルを1つ作って、frontmatterと本文を書くだけでよい。次のセクションでは、このファイルが実際にページとして表示されるまでの流れを見ていく。
コンテンツの利用
ここまでで設定とコンテンツが揃った。あとはNext.jsのページコンポーネントから.veliteの出力をimportして、ブラウザに表示するだけだ。
// app/posts/[slug]/page.tsx
import { posts } from '#site/content'
import { notFound } from 'next/navigation'
interface PostPageProps {
params: Promise<{ slug: string }>
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = posts.find((p) => p.slug === slug)
if (!post || post.draft) notFound()
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
{/* MDX本文のレンダリング */}
</article>
)
}
export function generateStaticParams() {
return posts
.filter((p) => !p.draft)
.map((p) => ({ slug: p.slug }))
}#site/contentはtsconfigで.veliteディレクトリに通したエイリアスで、ここからpostsをimportするとすべての記事データが型付き配列として手に入る。DBを叩くわけでもAPIを呼ぶわけでもない、ただのJavaScriptオブジェクトの配列だ。
post.find((p) => p.slug === slug)でURLに対応する記事を取り出し、notFound()で存在しない場合は404を返す。post.draftがtrueの下書き記事も同じ扱いにしている。
generateStaticParamsはNext.jsに「どのslugでページを作るか」を伝える関数だ。ここで返した分だけビルド時に静的HTMLが生成される。下書きをfilterで除外しているので、本番ビルドには公開記事だけが含まれる。
型付きで解決される恩恵は大きい。post.titleやpost.dateにはエディタ補完が効き、スキーマに無いフィールドへのアクセスはTypeScriptがコンパイル時に弾いてくれる。frontmatterを書き換えたときに、それを参照するコンポーネント側の修正漏れが即座に検出される。
ビルド時に静的生成される——SSGとSEO
前セクションで何気なく使ったgenerateStaticParamsは、このブログ構成のSEO性能を決定づける重要な要素だ。Velite + Next.jsの組み合わせで作られたページは、**SSG(Static Site Generation)**として動作する。
SSGとは何か
next buildを実行すると、Next.jsはgenerateStaticParamsで列挙されたすべてのパスに対して、ビルド時に静的HTMLファイルを生成する。ビルドログには次のような出力が並ぶ。
Route (app) Size First Load JS
○ / 1.2 kB 85 kB
● /posts/[slug] 2.1 kB 87 kB
├ /posts/hello-world
├ /posts/velite-intro
└ /posts/nextjs-tips
○はビルド時に1回だけ生成される完全静的ページ、●はgenerateStaticParamsで列挙されたパスを事前に静的HTMLとして生成したページを示す。記事が3本あれば3つのHTMLファイルが出来上がる。リクエストが来たときサーバーは何も計算せず、生成済みのHTMLをそのまま返すだけだ。
これはランタイムでサーバーが毎回レンダリングするSSR(Server-Side Rendering)とも、ブラウザ側でReactがDOMを組み立てるCSR(Client-Side Rendering)とも異なる方式になる。
rehypeプラグインでコンテンツを強化する
VeliteはMDXの処理パイプラインにrehypeプラグインを差し込める。ブログとして実用的な構成を紹介する。
// velite.config.ts
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
export default defineConfig({
mdx: {
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
[rehypePrettyCode, { theme: 'github-dark' }],
],
},
collections: { posts },
})
rehype-slugは各見出しにIDを付与し、rehype-autolink-headingsがそのIDへのリンクを自動生成する。この2つはセットで使うのが定石だ。rehype-pretty-codeはShikiベースのシンタックスハイライトで、VSCodeのテーマをそのまま適用できる。
この構成だけで、目次の自動生成に必要なアンカーリンクと、見栄えのするコードブロックが手に入る。
開発を始めるためのヒント
コンテンツのディレクトリ構成は、最初からカテゴリごとに分けるよりcontent/posts/にフラットに置いて始めるのがよい。Veliteのpatternはglobで柔軟に指定できるため、後からディレクトリを切り直しても設定変更は1行で済む。
draftフィールドを.default(false)で定義しておくのは小さいが重要なポイントだ。下書き記事のfrontmatterに毎回draft: trueを書くだけで本番ビルドから除外でき、フィルタリングはposts.filter(p => !p.draft)で完結する。
パフォーマンスの観点では、画像をpublic/に置いてfrontmatterで相対パスを指定し、Next.jsの<Image>コンポーネントで表示する構成が無難だ。Veliteには画像を直接処理する機能はないため、画像最適化はNext.js側(sharpパッケージ)に任せる形になる。
Veliteのリポジトリにはexamplesディレクトリがあり、Next.js App Routerとの統合例が用意されている。スキーマ設計やMDXコンポーネントの定義で迷ったら、まずそこを参照するのが最短ルートだ。