•10 min read

Next.js、Contentlayer、Git Submodulesでポートフォリオを構築した方法

Next.js、Contentlayer、Git Submodulesでポートフォリオを構築した方法

個人の開発者ポートフォリオを構築することは、エンジニアリングプロジェクトの中でも最もやりがいのあるものの一つです。それはあなたの技術的なサンドボックスであり、最先端のウェブ標準を試したり、深い技術ガイドを共有したり、完全に自分で所有する高性能なデジタルプレゼンスを構築したりする場所です。

locionic.com を設計するにあたり、私は3つの厳格なエンジニアリング制約を設けました。

  1. データベースとヘッドレスCMSをゼロに: 月額のSaaSデータベース料金、外部APIのダウンタイム、ベンダーロックインを排除。
  2. コンテンツをコードとして扱う: すべての技術記事、コードスニペット、チュートリアルは、Gitバージョン管理下のプレーンなMarkdown/MDXとして存在すること。
  3. グローバルなサブ秒パフォーマンス: 完璧な100/100のCore Web Vitals、事前レンダリングされた静的HTML、クライアントサイドのレイアウトシフトをゼロに。

このガイドでは、Gitサブモジュールによるコンテンツ分離とContentlayerのビルドスキーマから、React Server Component (RSC) ペイロードの最適化に至るまで、locionic.comの背後にある正確な本番アーキテクチャを詳しく解説します。


Next.js Architecture Illustration
Audio Briefing
0:00 / 0:00

1. コンテンツをコードとして扱うアーキテクチャ: Gitサブモジュールによる分離

ブログを構築するほとんどの開発者は、Markdownファイルをメインのアプリケーションリポジトリに直接保存するか、SanityやStrapiのような外部のヘッドレスCMSに接続します。どちらのアプローチにも顕著な欠点があります。

  • モノリシックなアプリリポジトリ: ブログが画像やローカライズされた翻訳(en、vi、ja)を含む200以上の記事に拡大すると、コードリポジトリのGit履歴が数千のコンテンツコミットで肥大化します。
  • ヘッドレスCMS: ビルド中に継続的なネットワーク呼び出し、複雑なWebhook同期パイプライン、月額サブスクリプションティアが必要になります。

サブモジュールソリューション

私はコードベースを2つの異なるリポジトリに分離しました。

  1. アプリケーションシェル (locionic/blog-and-projects): Next.jsのルート、Tailwindのスタイリング、Reactコンポーネント、ビルドスクリプトが含まれます。
  2. コンテンツコア (locionic/projects は contents/ にマウント): 純粋な.mdxファイル、技術チュートリアル、インタラクティブなチートシート、ローカライズされた翻訳のみが含まれます。
/home/developer/blog-and-projects/
├── app/                  # Next.js 14 App Router (RSC Layouts, API routes)
├── components/           # UI components, MDX custom widgets
├── contents/             # Git Submodule pointing to locionic/projects
│   ├── blog_dev/         # 200+ Production Technical Guides (.mdx)
│   ├── cheatsheets/      # Interactive CLI & Git Reference Guides
│   └── courses/          # Multi-lesson curriculum files
└── package.json

この分離されたアーキテクチャにより、アプリケーションのリビルドをトリガーしたり、Reactコードに触れたりすることなく、任意のMarkdownエディタやモバイルGitクライアントから技術ガイドを執筆、編集、公開できます。


Advertisement

2. Contentlayerによる型安全なコンテンツ処理

生の.mdxファイルをビルド時に厳密に型付けされたTypeScriptオブジェクトに変換するために、このサイトではContentlayerを利用しています。

// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrismPlus from 'rehype-prism-plus';

export const Blog = defineDocumentType(() => ({
  name: 'Blog',
  filePathPattern: 'blog_dev/**/*.mdx',
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    summary: { type: 'string', required: true },
    tags: { type: 'list', of: { type: 'string' }, default: [] },
    draft: { type: 'boolean', default: false },
    images: { type: 'list', of: { type: 'string' }, default: [] },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (doc) => doc._raw.flattenedPath.replace(/^blog_dev\//, '').replace(/\/[^/]+$/, ''),
    },
    readingTime: {
      type: 'json',
      resolve: (doc) => readingTime(doc.body.raw),
    },
  },
}));

Contentlayerはすべての.mdxファイルを読み込み、YAMLフロントマターを解析し、型を検証し、.contentlayer/generatedに静的なJSONドキュメントを生成します。もし私がsummaryが欠落している記事や、dateが不正な記事をコミットした場合、CIでビルドが即座に失敗し、正確な行番号が示されます。


3. RSCシリアライゼーションの罠: 2MBの隠れたページウェイトを削減

本番プロファイリング中に発見された最も重要なパフォーマンスバグの1つは、React Server Components (RSC) ペイロードのシリアライゼーションに関するものでした。

初期のビルドでは、ブログ記事のレイアウトは各記事の下部に「関連投稿」ウィジェットを表示していました。

// ❌ DANGEROUS: Leaking 2MB of MDX body code into every page's RSC payload
import { allBlogs } from 'contentlayer/generated';

export default function BlogPostPage({ params }) {
  const post = allBlogs.find((p) => p.slug === params.slug);
  // Passing full Contentlayer objects to a Client Component!
  return <PostLayout post={post} allPosts={allBlogs} />;
}

allBlogsには200以上の全記事の生のコンパイル済みJavaScriptコード(body.code)が含まれているため、Next.jsは200記事のカタログ全体をすべてのブログ記事の__NEXT_DATA__ / RSC JSONペイロードにシリアライズしていました。単純な記事ページが初回ロード時に2.3 MBの隠れたJSONをダウンロードしていたのです!

解決策: coreContent()によるサーバーサイドでのストリッピング

サーバーとクライアントの境界を越えてプロパティをシリアライズする前に、ビルド時のフィールドをすべて削除するようにデータレイヤーをリファクタリングしました。

// ✅ OPTIMAL: Only ship lightweight metadata to client components
export function coreContent<T extends { body: unknown; _raw: unknown }>(content: T) {
  const { body, _raw, _id, type, ...rest } = content;
  return rest;
}

// Pass only 3 slim related post summaries
const relatedPosts = getRelatedPosts(post, allBlogs, 3).map(coreContent);

この単一の最適化により、初期ページウェイトは2.3 MBから82 KBに削減され、帯域幅が96%削減され、モバイルのLighthouseスコアが99に急上昇しました。


4. アルゴリズムによる内部リンク: 10クラスターのハミルトン環

検索エンジンの孤立ペナルティを回避し、トピックの権威を最大化するために、このサイトでは自動グラフ理論スクリプト(scripts/relink_cluster_mesh.py)を実行しています。

  1. TF-IDF意味的クラスタリング: 全219記事を10の異なる技術クラスター(nextjs_react、ai_llm_rag、devops_cloud、systems_wasm)に分類します。
  2. ハミルトン4弦環: 各クラスター内で連続した閉じた環を形成し、各記事を最も近い4つの意味的隣接記事に接続します。
  3. 保証されたトポロジー: すべての記事が少なくとも4つの入次数と出次数を持つことを保証し、ドメイン全体で孤立記事を正確に0に保ちます。

Advertisement

技術アーキテクチャの概要

アーキテクチャ層選択された技術主なエンジニアリング上の理由
フレームワークNext.js 14 App Router静的サイト生成 (SSG) + サーバーコンポーネント
スタイリングTailwind CSS + TypographyランタイムCSS-in-JSオーバーヘッドゼロ
コンテンツエンジンContentlayer 0.3.4MDXのビルド時型検証
検索エンジンFlexSearch / 静的ビルドAlgoliaのコストなしで100%クライアントサイド検索
ホスティング & CDNVercel Edge NetworkグローバルHTTP/3キャッシングとEdge OG画像レンダリング
アナリティクス & プライバシーGoogle Analytics + CookieConsentGDPR準拠のクッキー同意フラグ

よくある質問

データベースなしでクライアントサイド検索はどのように機能しますか?

ビルド後のステップで、scripts/generate-search.mjsは、公開されたすべての記事のタイトル、要約、タグを含むミニファイされたpublic/search.jsonファイルをコンパイルします。ユーザーがCmd+Kを押すと、検索モーダルがこのインデックスをメモリにロードし、サブミリ秒のレイテンシで即座に検索します。

ソーシャルOpenGraphプレビュー画像はどのように生成されますか?

Next.jsのEdgeルート/api/og/[locale]/[slug]を利用しています。@vercel/og(Satoriをバックエンドとする)を使用して、サーバーは投稿タイトル、著者名、ブランドを含む動的で高解像度の1200x630 PNG画像をオンザフライで生成し、1年間の積極的な不変エッジキャッシングを行います。

コードのシンタックスハイライトはどのように処理していますか?

MDXのコードブロックは、カスタムのPrism CSSテーマを使用してrehype-prism-plusでビルド時に事前レンダリングされます。これにより、シンタックスハイライト用のJavaScriptはブラウザに一切送信されず、シンタックストークンはネイティブHTMLの<span>要素に事前フォーマットされます。


こちらもおすすめです

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement