Next.js + React + TypeScript + MDX 部落格架構

Next.js + React + TypeScript + MDX 部落格架構
Ian Chou
Ian Chou

深入介紹基於 Next.js、React、TypeScript 和 MDX 的現代部落格架構設計。本文詳細說明如何實現分離式元數據管理、純客戶端渲染、局部與全域 MDX 元件,以及完整的實作指南。適合想建立技術部落格的開發者參考。

Next.jsReactTypeScriptMDX部落格架構

前言

現代部落格系統需要兼顧開發效率、內容管理靈活性和良好的用戶體驗。本文將深入介紹一個基於 Next.js、React、TypeScript 和 MDX 的部落格架構方案,該方案源自實際專案經驗,特別適合喜歡以程式碼為中心的技術寫作者。

核心特色

  1. 分離式元數據管理(TypeScript Metadata)
    所有文章的 metadata(如標題、日期、作者、標籤等)統一集中於 TypeScript 檔案(如 metadata.ts),而非分散在每篇 MDX 的 frontmatter。
    這種方式帶來更強的型別安全、易於全站維護、批次編輯與自動化處理,並能避免 YAML 語法錯誤導致的編譯問題。

  2. 純客戶端渲染,避免水合(Hydration)問題
    所有 MDX 內容透過 Next.js 的客戶端渲染機制處理,確保互動式元件與 React hook 能在各種情境下正常運作,並減少 SSR/CSR 不一致導致的水合錯誤。

  3. 局部 MDX 元件支援
    每篇文章資料夾下可自訂專屬的 React 元件(如 components/CustomComponent.tsx),實現內容與元件的高度解耦與重用,讓每篇文章都能擁有獨特的互動體驗。

  4. 全域 MDX 元件庫
    提供一套可跨文章共用的全域元件(如 Alert、YouTube、Mermaid 等),方便維護統一的設計風格與互動行為,提升開發效率。

  5. 完整 TypeScript 強型別整合
    metadata、內容、元件全程 TypeScript 型別檢查,避免欄位遺漏或型別錯誤,提升專案穩定性與維護性。

  6. 高效建構流程
    基於 Next.js 現代架構,結合集中 metadata 管理與模組化元件設計,讓整個內容站點的建構、部署與擴展都更快速、可靠。

目錄結構

project/
├── app/                     # Next.js App Router
│   └── blog/
│       └── [slug]/
│           ├── page.tsx     # 動態載入 MDX 與元件
│           ├── MDXRenderer.tsx # MDX 渲染器
│           └── ClientMDXContent.tsx # 客戶端MDX容器
├── content/                 # 內容檔案
│   ├── metadata.ts          # 所有文章的元數據
│   └── posts/
│       └── [post-slug]/
│           ├── content.mdx  # 文章內容
│           └── components/  # 文章專用元件
│               ├── index.ts # 元件匯出索引
│               └── CustomComponent.tsx # 文章專用元件
├── components/
│   └── mdx/
│       └── global-components/ # 全域 MDX 元件
│           ├── index.ts      # 全域元件匯出索引
│           └── Alert.tsx     # 全域可用的元件
└── lib/
    └── mdx-loader.ts        # 元件載入系統

路由段落共置與 MDX 組件自動注入機制

在 Next.js(特別是 App Router + MDX)架構下,您可以充分利用「路由段落共置」與「MDX 組件自動注入」的現代開發體驗:

1. 路由段落的檔案共置(Colocation)

  • App Router 段落機制
    Next.js 13+ 的 App Router 會將 app/ 目錄下的每個子資料夾視為「路由段落(route segment)」,只有包含 page.tsxlayout.tsxroute.ts 的資料夾才會對外公開成為路由頁面。
  • 非路由檔案自動共置
    在同一段落資料夾下新增的其他檔案(如 components/index.tsutils.ts 等)不會生成額外路由,但可直接在同段落內被其他檔案引用,提升模組化與維護性。

2. MDX 插件自動注入同目錄組件

  • 自動載入組件
    Next.js 的 MDX 插件(@next/mdx)在編譯 MDX 檔案時,會自動尋找同目錄下的 components.tsxcomponents/index.ts,並將其所有 export 的 React 元件注入到 MDX 的 components prop。
  • 免手動 import
    這代表您在 MDX 檔案中可以直接使用
    <Button>
    <Alert>
    
    等自訂元件,而無需再手動 import,極大提升開發效率與可讀性。

實作範例

app/
└── posts/
    └── [slug]/
        ├── page.mdx               # MDX 內容,無需 import 組件
        └── components/
            └── index.ts           # 匯出 Button、Alert 等元件

MDX 內容只需:

# 我的文章

<Button>按我</Button>

無需再寫 import Button from './components',Next.js 會自動注入。

3. 機制原理與開發便利

  • 共置檔案不生成路由
    只要不是 page.tsxlayout.tsx 等特殊檔案,其他檔案都只是共置模組,不會被當成頁面公開。
  • MDXProvider 省略
    傳統 React 需用 <MDXProvider components={...}> 注入元件,Next.js 架構已自動完成這步驟,讓 MDX 檔案可直接使用元件標籤。
  • 提升維護性
    這種設計讓內容、元件與邏輯可自然共置,提升專案的可維護性、可讀性與團隊協作效率。

延伸閱讀:可參考 Next.js 官方「配置 MDX」與「路由共置」文件,了解更多細節。

設計理念

為何分離元數據和內容?

傳統的 MDX 文件常使用 YAML frontmatter 來管理元數據,但這種方式存在以下問題:

  1. 解析衝突:MDX 解析器和 TypeScript 編譯器可能競爭解析同一個檔案
  2. 需額外配置:MDX 預設不支援 frontmatter,需配置插件
  3. 擴展名限制:僅 .mdx 檔案能正確使用 frontmatter
  4. 維護困難:元數據和內容混合使維護更複雜

因此,本架構採用了獨立的 TypeScript 檔案 metadata.ts 來儲存所有文章的元數據:

// content/metadata.ts
export const postsMetadata: Record<string, BlogMetadata> = {
  'getting-started-with-nextjs': {
    title: '開始使用 Next.js 15',
    date: '2025-05-10',
    excerpt: '學習如何使用 Next.js 15 建立現代化的 React 應用程式...',
    author: '作者名稱',
    tags: ['Next.js', 'React', 'TypeScript'],
    coverImage: '/images/getting-started-with-nextjs.webp'
  },
  // 其他文章...
};

元件架構

本架構支援兩類 MDX 元件:

  1. 全域元件:位於 components/mdx/global-components/ 目錄,所有文章可用
  2. 局部元件:位於 content/posts/[slug]/components/ 目錄,只有特定文章可用

當全域和局部元件同名時,局部元件優先。這允許文章覆寫全域元件的行為。

實作細節

1. 文章載入流程

文章載入由 lib/mdx.ts 中的函數處理:

// 取得特定文章的內容和元數據
export function getPostBySlug(slug: string): { metadata: BlogMetadata; content: string } | null {
  const metadata = getPostMetadata(slug);
  
  if (!metadata) {
    return null;
  }

  const fullPath = path.join(postsDirectory, slug, 'content.mdx');
  
  if (!fs.existsSync(fullPath)) {
    return null;
  }

  const fileContents = fs.readFileSync(fullPath, 'utf8');

  return {
    metadata,
    content: fileContents,
  };
}

2. 元件載入系統

元件載入由 lib/mdx-loader.ts 處理,它會動態載入全域和局部元件:

export const getPostComponents = cache(async (slug: string) => {
  try {
    // 檢查該文章是否有自定義元件目錄
    const componentsDir = path.join(process.cwd(), 'content/posts', slug, 'components');
    
    if (!fs.existsSync(componentsDir)) {
      // 返回全域元件
      return { ...globalComponents };
    }

    // 嘗試導入局部元件
    try {
      const postComponents = await import(`@content/posts/${slug}/components/index`);
      
      // 合併全域元件和局部元件
      return { 
        ...globalComponents,
        ...postComponents  // 局部元件優先
      };
    } catch (importError) {
      return { ...globalComponents };
    }
  } catch (error) {
    return { ...globalComponents };
  }
});

3. MDX 渲染流程

MDX 內容透過 next-mdx-remote 處理和渲染:

// MDXRenderer.tsx
export default function MDXRenderer({ source, components }: MDXRendererProps) {
  const [mdxSource, setMdxSource] = useState<MDXRemoteSerializeResult | null>(null);

  // 合併全域和局部元件
  const mergedComponents = {
    ...globalComponents,
    ...components  // 局部元件可覆寫全域元件
  };

  useEffect(() => {
    const processMDX = async () => {
      try {
        const serialized = await serialize(source, {
          parseFrontmatter: true
        });

        setMdxSource(serialized);
      } catch (err) {
        console.error('Error processing MDX:', err);
      }
    };
    
    processMDX();
  }, [source]);

  if (!mdxSource) {
    return <div>載入中...</div>;
  }

  return (
    <div className="mdx-content prose prose-lg max-w-none">
      <MDXRemote {...mdxSource} components={mergedComponents} />
    </div>
  );
}

4. 動態路由設定

Next.js 的 App Router 透過 [slug] 處理動態路由,並使用 generateStaticParams 進行靜態產生:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const resolvedParams = await params;
  const post = getPostBySlug(resolvedParams.slug);
  
  if (!post) {
    notFound();
  }

  // 取得該文章的自定義元件
  const components = await getPostComponents(resolvedParams.slug);

  return (
    <BlogPostContentStatic metadata={post.metadata}>
      <MDXRenderer 
        source={post.content} 
        components={components}
      />
    </BlogPostContentStatic>
  );
}

實作指南

1. 建立 Next.js 專案

npx create-next-app@latest my-blog --typescript --eslint
cd my-blog

2. 安裝必要依賴

npm install next-mdx-remote @mdx-js/react @mdx-js/loader

3. 設定目錄結構

建立前述的目錄結構,包括 content、components/mdx 等資料夾。

project/
├── app/                     # Next.js App Router
│   └── blog/
│       └── [slug]/
│           ├── page.tsx     # 動態載入 MDX 與元件
│           ├── MDXRenderer.tsx # MDX 渲染器
│           └── ClientMDXContent.tsx # 客戶端MDX容器
├── content/                 # 內容檔案
│   ├── metadata.ts          # 所有文章的元數據
│   └── posts/
│       └── [post-slug]/
│           ├── content.mdx  # 文章內容
│           └── components/  # 文章專用元件
│               ├── index.ts # 元件匯出索引
│               └── CustomComponent.tsx # 文章專用元件
├── components/
│   └── mdx/
│       └── global-components/ # 全域 MDX 元件
│           ├── index.ts      # 全域元件匯出索引
│           └── Alert.tsx     # 全域可用的元件
└── lib/
    └── mdx-loader.ts        # 元件載入系統

4. 配置 Next.js

更新 next.config.js 以支援 MDX:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  // 視需要添加其他配置
};

export default nextConfig;

5. 新增類型定義

app/types/blog.ts 中定義部落格類型:

export interface BlogMetadata {
  title: string;
  date: string;
  excerpt: string;
  author: string;
  tags: string[];
  coverImage?: string;
}

export interface BlogPost {
  slug: string;
  title: string;
  date: string;
  excerpt: string;
  author: string;
  tags: string[];
  coverImage?: string;
}

6. 建立全域 MDX 元件

components/mdx/global-components 目錄中建立共用元件,例如:

// components/mdx/global-components/Alert.tsx
'use client';

import React from 'react';

interface AlertProps {
  type?: 'info' | 'warning' | 'error';
  children: React.ReactNode;
}

export function Alert({ type = 'info', children }: AlertProps) {
  const bgColor = {
    info: 'bg-blue-50 border-blue-200 text-blue-800',
    warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
    error: 'bg-red-50 border-red-200 text-red-800',
  }[type];

  return (
    <div className={`p-4 my-4 rounded-md border ${bgColor}`}>
      {children}
    </div>
  );
}

並在 index.ts 中匯出:

// components/mdx/global-components/index.ts
export * from './Alert';
// 匯出其他全域元件...

7. 撰寫文章內容

content/posts/[slug]/content.mdx 中撰寫文章內容:

# 開始使用 Next.js 15

這是一篇關於 Next.js 15 的入門教學。

<Alert type="info">
  Next.js 15 帶來了許多新功能和改進!
</Alert>

## 主要特點

- App Router
- Server Components
- 改進的快取機制

8. 建立文章專屬元件(可選)

如果要為特定文章建立專屬元件:

// content/posts/your-post/components/VersionComparison.tsx
'use client';

import React from 'react';

export function VersionComparison() {
  return (
    <div className="my-6 overflow-x-auto">
      <table className="min-w-full border-collapse">
        <thead>
          <tr className="bg-gray-100">
            <th className="border p-2">功能</th>
            <th className="border p-2">Next.js 14</th>
            <th className="border p-2">Next.js 15</th>
          </tr>
        </thead>
        <tbody>
          {/* 表格內容 */}
        </tbody>
      </table>
    </div>
  );
}

並在該文章的 components/index.ts 中匯出:

// content/posts/your-post/components/index.ts
export * from './VersionComparison';

使用技巧

1. 在 MDX 中直接使用元件

無需導入,直接在 MDX 中使用元件名稱:

## 版本比較

<VersionComparison />

<Alert type="warning">
  請注意,某些功能可能需要額外配置。
</Alert>

2. 動態生成元數據

page.tsx 中的 generateMetadata 函數處理 SEO 元數據:

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = getPostBySlug(params.slug);
  
  if (!post) {
    return {};
  }

  const { metadata } = post;
  
  return {
    title: `${metadata.title} | My Blog`,
    description: metadata.excerpt,
    openGraph: {
      // Open Graph 相關設定
    }
  };
}

3. 實現交互式元件

在局部元件中實現文章專屬的交互式元件:

'use client';

import { useState } from 'react';

export function InteractiveDemo() {
  const [count, setCount] = useState(0);
  
  return (
    <div className="my-6 p-4 border rounded-md">
      <p>點擊次數: {count}</p>
      <button 
        onClick={() => setCount(count + 1)}
        className="px-4 py-2 bg-blue-500 text-white rounded-md"
      >
        增加
      </button>
    </div>
  );
}

最佳實踐

  1. 元件職責清晰:全域元件應為通用功能,局部元件專注於特定文章需求
  2. 命名一致性:使用 PascalCase 命名所有元件
  3. 類型安全:為所有元件添加適當的 TypeScript 型別
  4. 客戶端指令:對交互式元件使用 'use client' 指令
  5. 元件索引檔案:透過 index.ts 匯出元件,保持導入路徑簡潔

效能考量

  1. 圖片最佳化:使用 Next.js 的 <Image> 元件處理圖片
  2. 代碼分割:局部元件自動實現細粒度代碼分割
  3. 靜態產生:使用 generateStaticParams 在建構時產生靜態頁面
  4. 元件選擇性載入:只載入每篇文章需要的元件

擴展方向

  1. 搜尋功能:實現基於元數據的文章搜尋
  2. 標籤分類:根據文章標籤實現分類頁面
  3. 國際化:添加多語言支援
  4. 主題切換:實現深色/淺色模式切換
  5. 分析整合:添加訪問分析功能

結語

這個基於 Next.js、React、TypeScript 和 MDX 的部落格架構提供了強大的彈性和開發體驗。透過分離元數據和內容、支援局部元件,以及完整的 TypeScript 整合,使得技術部落格的開發和維護變得更加高效和愉快。

無論是個人技術部落格還是團隊知識庫,這個架構都能很好地滿足需求,並可根據專案規模進行適當調整和擴展。

Thanks for reading!

Found this article helpful? Share it with others or explore more content.

More Articles
Published May 16, 202516 min read5 tags