VeliteでNext.jsブログを構築する — MDXコンテンツを型安全に扱う実践ガイド(理解が難しい、、)

Next.jsでブログやドキュメントサイトを構築するとき、避けて通れないのが「Markdownファイルをどう管理・変換するか」という問題だ。

ヘッドレスCMSを使うほどではないが、fs.readFileSyncでゴリゴリ読み込むのも辛い。frontmatterのパースやMDXの処理、型定義の手書き——こうした”コンテンツ周りの配管工事”に時間を取られた経験があるエンジニアは少なくないだろう。

Veliteは、この課題をビルド時のスキーマ駆動で解決するコンテンツフレームワークだ。かつてContentlayerが担っていた領域をカバーしつつ、よりシンプルな設計で実用的な選択肢になっている。

目次

Veliteとは何か

Veliteは、ローカルのMarkdown/MDX/YAMLファイルをビルド時に型安全なJSONデータへ変換するツールだ。Zodベースのスキーマでコンテンツの構造を定義し、ビルド時にバリデーションと変換を一括で行う。

https://velite.js.org

似た役割を持つContentlayerは2023年後半から開発が停滞し、Next.js 14以降との互換性に問題を抱えている。Veliteはその代替として2024年に登場し、積極的にメンテナンスされている。

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

観点ContentlayerVelite
メンテナンス状況停滞(2023年後半〜)活発
Next.js 15対応
公式は停滞、コミュニティフォーク(contentlayer2)で対応
公式対応
スキーマ定義独自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を叩くわけではないため、ビルド時にすべてが確定し、ランタイムのオーバーヘッドはゼロだ。

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は記事のメタデータ、本文は記事の中身だ。

frontmatter(メタデータ) title: “はじめての記事” 本文(Markdown / MDX) # 本文のタイトル ここに記事の内容を書きます。

この2ブロック構造はVelite特有のものではなく、一般的な静的サイトジェネレータやブログツールで広く使われている記法だ。

frontmatterに何を書くか

frontmatterに書くのは記事のメタデータ——タイトル、日付、タグなど、本文とは別に管理したい情報だ。

ここで書けるキーは、前セクションで定義したvelite.config.tsのスキーマで決まる

たとえばスキーマ側でtitle: s.string()と宣言していれば、frontmatterにtitleを書かないとビルドが通らない。スキーマに無いキーをfrontmatterに書いても単に無視される。

frontmatter title: “はじめての記事” date: “2025-01-15” description: “…” draft: false スキーマ(velite.config.ts) title: s.string() date: s.isodate() description: s.string() draft: s.boolean() ↑ ここで書けるキーを決めている

この対応関係があるおかげで、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.titlepost.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)とも異なる方式になる。

SSG(Veliteの構成) next build HTMLを事前生成 CDN / サーバー 生成済みHTML配置 クローラー 即HTML取得 SSR リクエスト 毎回サーバー処理 毎回HTML生成 計算コスト発生 クローラー HTML取得 CSR 空のHTML配信 JS実行でDOM構築 JSを実行しない場合あり

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

目次