Next.jsでブログやドキュメントサイトを構築するとき、避けて通れないのが「Markdownファイルをどう管理・変換するか」という問題だ。ヘッドレスCMSを使うほどではないが、fs.readFileSyncでゴリゴリ読み込むのも辛い。frontmatterのパースやMDXの処理、型定義の手書き——こうした”コンテンツ周りの配管工事”に時間を取られた経験があるエンジニアは少なくないだろう。
Veliteは、この課題をビルド時のスキーマ駆動で解決するコンテンツフレームワークだ。かつてContentlayerが担っていた領域をカバーしつつ、よりシンプルな設計で実用的な選択肢になっている。本記事では、Veliteの仕組みを理解し、Next.jsブログに組み込むまでの流れを解説する。
Veliteとは何か
Veliteは、ローカルのMarkdown/MDX/YAMLファイルをビルド時に型安全なJSONデータへ変換するツールだ。Zodベースのスキーマでコンテンツの構造を定義し、ビルド時にバリデーションと変換を一括で行う。
似た役割を持つContentlayerは2023年後半から開発が停滞し、Next.js 14以降との互換性に問題を抱えている。Veliteはその代替として2024年に登場し、積極的にメンテナンスされている。
両者の位置づけを整理する。
| 観点 | Contentlayer | Velite |
|---|---|---|
| メンテナンス状況 | 停滞(2023年後半〜) | 活発 |
| Next.js 15対応 | 非公式パッチが必要 | 公式対応 |
| スキーマ定義 | 独自DSL | Zod |
| 設定ファイル | contentlayer.config.ts | velite.config.ts |
| 出力先 | .contentlayer/generated | .velite |
| MDXサポート | あり | あり |
Zodでスキーマを書くという設計判断が重要で、既存のZodの知識がそのまま使え、フォームバリデーションなど他の用途で定義した型との一貫性も保てる。
Veliteの仕組み
ビルドパイプライン
Veliteの処理は明確な3ステップで構成される。まずコンテンツファイルを読み取り、次にスキーマに従ってバリデーション・変換を行い、最後に型付きJSONとTypeScript型定義を出力する。この流れを図にすると以下のようになる。
ビルド結果は.veliteディレクトリに格納され、通常のESモジュールとしてimportできる。CMSのAPIを叩くわけではないため、ビルド時にすべてが確定し、ランタイムのオーバーヘッドはゼロだ。
スキーマ定義の考え方
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 },
})
sはVelite独自のスキーマビルダーで、Zodを拡張してコンテンツ特有のヘルパーを追加したものだ。s.slug()はファイルパスからスラッグを自動生成し、s.mdx()は本文をコンパイル済みMDXコードに変換する。s.isodate()は日付文字列をISO形式でバリデーションする。
これらはすべてZod互換なので、.max()や.default()といったおなじみのメソッドがそのまま使える。
Next.jsプロジェクトへの導入
インストール
npm install velite -D
Veliteはdevdependencyとして入れる。ビルド時にのみ動作するため、本番バンドルには含まれない。
Next.js設定の統合
next.config.mjsにVeliteのwebpackプラグインを組み込む。
// next.config.mjs
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"]
}
}
}
コンテンツの利用
あとはページコンポーネントから直接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 }))
}
postsは型付きの配列として解決されるため、post.titleやpost.dateにはエディタの補完が効く。存在しないフィールドへのアクセスはTypeScriptがコンパイル時に弾いてくれる。
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コンポーネントの定義で迷ったら、まずそこを参照するのが最短ルートだ。