VeliteでNext.jsブログを構築する — MDXコンテンツを型安全に扱う実践ガイド

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年に登場し、積極的にメンテナンスされている。

両者の位置づけを整理する。

観点ContentlayerVelite
メンテナンス状況停滞(2023年後半〜)活発
Next.js 15対応非公式パッチが必要公式対応
スキーマ定義独自DSLZod
設定ファイルcontentlayer.config.tsvelite.config.ts
出力先.contentlayer/generated.velite
MDXサポートありあり

Zodでスキーマを書くという設計判断が重要で、既存のZodの知識がそのまま使え、フォームバリデーションなど他の用途で定義した型との一貫性も保てる。

Veliteの仕組み

ビルドパイプライン

Veliteの処理は明確な3ステップで構成される。まずコンテンツファイルを読み取り、次にスキーマに従ってバリデーション・変換を行い、最後に型付きJSONとTypeScript型定義を出力する。この流れを図にすると以下のようになる。

コンテンツファイル .md / .mdx / .yaml Velite Zodスキーマで バリデーション+変換 出力 .velite/ ディレクトリ index.js(型付きJSONデータ) index.d.ts(TypeScript型定義) Next.jsのページコンポーネントからimport

ビルド結果は.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.titlepost.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コンポーネントの定義で迷ったら、まずそこを参照するのが最短ルートだ。

目次