用 Notion 打造部落格:Next.js 完整實作指南用 Notion 打造部落格:Next.js 完整實作指南
用 Notion 當 CMS,透過 API 抓取文章資料並轉成 Markdown,在 Next.js App Router 中渲染成完整的部落格頁面。適合想用 Notion 管理內容、又不想碰傳統 CMS 的開發者。
用 Notion 打造部落格:Next.js 完整實作指南
適用技術棧:Next.js 15+ (App Router) / React 19 / TypeScript / Tailwind CSS
前陣子在做一個行銷網站的部落格時,發現直接拿 Notion 當 CMS 比想像中順手。團隊本來就在 Notion 寫文章,省掉了所有「把內容搬到另一個系統」的摩擦。整個流程就是:在 Notion 寫完、狀態改成已發佈,網站就自動有了。這篇整理了完整的實作邏輯,從 Notion API 串接到 Markdown 渲染,可以直接拿去用。
1. 這個系統做什麼
概覽
一個以 Notion Database 作為內容管理後台的部落格系統。透過 Notion API 讀取資料庫中的文章列表和內容,將 Notion Blocks 轉換成 Markdown,再用 React Markdown 渲染成網頁。支援文章列表頁、單篇文章頁、SEO metadata、JSON-LD 結構化資料、圖片畫廊、相關文章推薦。
解決什麼問題
- 團隊已經在 Notion 寫內容,不想再學另一套 CMS
- 需要一個零成本(不需要額外 DB 或 CMS 服務)的部落格解決方案
- 希望非工程人員也能發佈文章(改個 Notion 狀態就好)
功能邊界(Scope)
- 包含:文章列表(含精選文章)、單篇文章渲染(Markdown + 圖片畫廊)、動態 SEO metadata、JSON-LD、相關文章、ISR 快取策略、slug fallback(Notion page ID)
- 不包含:評論系統、全文搜尋、分頁、分類篩選頁面、RSS feed
2. 整體架構與資料流
資料流
Notion Database (CMS)
│
▼ Notion REST API (fetch)
┌───────────────┐
│ notion.ts │ getPublishedPosts() → 文章列表
│ (資料層) │ getPostBySlug() → 單篇文章 + Markdown
└───────┬───────┘
│
▼ notion-to-md
┌───────────────┐
│ Markdown │ Notion Blocks → Markdown string
└───────┬───────┘
│
▼ Server Component (Next.js App Router)
┌───────────────────────────────────┐
│ /blog/page.tsx 文章列表頁 │ revalidate = 60s
│ /blog/[slug]/page.tsx 單篇文章頁 │ revalidate = 600s
└───────┬───────────────────────────┘
│
▼ Client Component
┌───────────────────────────────────┐
│ ArticleContent.tsx Markdown 渲染 │ react-markdown + remark-gfm
│ ImageGallery.tsx 圖片畫廊 │ embla-carousel-react
│ BlogPostCard.tsx 文章卡片 │
└───────────────────────────────────┘
來源檔案參考(僅供對照,非必要)
以下路徑僅標記程式碼出處,閱讀此 PRD 不需要存取這些檔案。
src/lib/notion.ts | 資料層 | Notion API 串接、資料映射 |
src/app/blog/page.tsx | 入口 | 文章列表頁 |
src/app/blog/[slug]/page.tsx | 入口 | 單篇文章頁、SEO、JSON-LD |
src/components/blog/ArticleContent.tsx | 核心邏輯 | Markdown 渲染、圖片畫廊偵測、Notion 連結轉換 |
src/components/blog/ImageGallery.tsx | UI | Embla Carousel 圖片畫廊 |
src/components/blog/BlogPostCard.tsx | UI | 列表頁文章卡片(精選 + 一般) |
src/components/blog/RelatedPostCard.tsx | UI | 相關文章卡片 |
外部依賴
| 依賴 | 類型 | 版本 | 用途 |
|---|
@notionhq/client | npm | ^5.7.0 | Notion SDK(僅用於 notion-to-md 初始化) |
notion-to-md | npm | ^3.1.9 | 將 Notion Blocks 轉成 Markdown |
react-markdown | npm | ^10.1.0 | Markdown 渲染成 React 元件 |
remark-gfm | npm | ^4.0.1 | 支援 GFM(表格、刪除線等) |
rehype-raw | npm | ^7.0.0 | 允許 Markdown 中的 HTML 標籤 |
embla-carousel-react | npm | ^8.6.0 | 圖片畫廊輪播(選用) |
環境變數 / 設定
NOTION_TOKEN=secret_xxx # Notion Integration Token
NOTION_BLOG_DATABASE_ID=abc123 # Notion Database ID(可帶或不帶 dash)
NEXT_PUBLIC_SITE_URL=https://your-site.com # 用於 JSON-LD author URL
3. 資料結構與型別定義
Notion Database Schema
| 屬性名稱 | Notion 類型 | 說明 |
|---|
| 名稱 | Title | 文章標題(Notion 頁面標題) |
| 網址 | Rich Text | URL slug,空值時 fallback 到 page ID |
| 描述 | Rich Text | 文章摘要 |
| 發布日期 | Date | 發佈日期 |
| 分類 | Multi-select | 文章分類(取第一個) |
| 狀態 | Status | 「已發佈」= 上線 |
| 封面 | Files | 封面圖(也會檢查 page.cover) |
注意:屬性名稱用繁體中文是這個專案的設定。你可以改成英文,只要在程式碼中對應修改 property key 即可。
輸出型別:文章列表
interface NotionPost {
id: string;
slug: string; // 網址 property 或 page ID
title: string;
excerpt: string;
coverImage: string; // 封面圖 URL
date: string; // YYYY-MM-DD
category: string;
author: {
name: string;
avatar: string;
role: string;
};
published: boolean;
}
輸出型別:單篇文章
interface PostDetail {
id: string;
slug: string;
title: string;
content: string; // Markdown 字串
date: string;
category: string;
coverImage: string | null;
author: {
name: string;
avatar: string;
role: string;
};
}
4. 一步步實作
坦白說這部分是整個功能最核心的地方,建議照順序走一遍。每個 Step 的程式碼都是完整可用的,不需要回去看原始 repo。
Step 1:初始化 Notion Client 和 Markdown 轉換器
目的:建立 Notion API 連線,準備 Block-to-Markdown 轉換工具。
import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";
const notion = new Client({
auth: process.env.NOTION_TOKEN,
});
const n2m = new NotionToMarkdown({ notionClient: notion });
@notionhq/client 在這裡只是給 notion-to-md 用的,實際 API 呼叫用的是原生 fetch(為了更好的錯誤處理和彈性)
Step 2:取得已發佈文章列表
目的:從 Notion Database 查詢所有狀態為「已發佈」的文章,按日期倒序排列。
// Helper:Notion Database ID 有時候會拿到沒有 dash 的 32 字元格式
const formatDatabaseId = (id: string) => {
if (id.length === 32) {
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
}
return id;
};
const getPublishedPosts = async (): Promise<NotionPost[]> => {
const rawDbId = process.env.NOTION_BLOG_DATABASE_ID;
if (!rawDbId) return [];
const databaseId = formatDatabaseId(rawDbId);
const response = await fetch(
`https://api.notion.com/v1/databases/${databaseId}/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
filter: {
property: "狀態", // 你的 Status property 名稱
status: { equals: "已發佈" } // 你的「已發佈」狀態名稱
},
sorts: [{
property: "發布日期", // 你的 Date property 名稱
direction: "descending",
}],
}),
}
);
if (!response.ok) {
console.error(`Notion API Error: ${response.status}`);
return [];
}
const data = await response.json();
return data.results.map((page: any) => {
const props = page.properties;
// 映射 Notion properties 到你的型別
const title = props.名稱?.title?.[0]?.plain_text || "Untitled";
const slug = props.網址?.rich_text?.[0]?.plain_text || page.id;
const excerpt = props.描述?.rich_text?.[0]?.plain_text || "";
const date = props.發布日期?.date?.start || new Date().toISOString().split("T")[0];
const category = props.分類?.multi_select?.[0]?.name || "Uncategorized";
// 封面圖:優先 page.cover,再 fallback 到 封面 property
let coverImage: string | null = null;
if (page.cover?.type === "external") {
coverImage = page.cover.external.url;
} else if (page.cover?.type === "file") {
coverImage = page.cover.file.url;
} else if (props.封面?.files?.length > 0) {
const fileObj = props.封面.files[0];
coverImage = fileObj.type === "external"
? fileObj.external.url
: fileObj.file.url;
}
return {
id: page.id,
slug,
title,
excerpt,
coverImage,
date,
category,
author: {
name: "Your Name",
avatar: "/avatar.jpg",
role: "Author",
},
published: true,
};
});
};
- Notion API 的
file 類型 URL 有過期時間(約 1 小時),如果你做 SSG 要注意 revalidate 設定
formatDatabaseId 是因為從 Notion URL 複製的 ID 有時不帶 dash
- Property 名稱是 case-sensitive 的,確認跟你 Notion Database 完全一致
Step 3:取得單篇文章(含 Markdown 內容)
目的:根據 slug 查詢單篇文章,並將 Notion Blocks 轉成 Markdown。
const getPostBySlug = async (slug: string) => {
const rawDbId = process.env.NOTION_BLOG_DATABASE_ID;
if (!rawDbId) return null;
const databaseId = formatDatabaseId(rawDbId);
// Step 3a:先用 slug 在「網址」property 中查詢
let response = await fetch(
`https://api.notion.com/v1/databases/${databaseId}/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
filter: {
property: "網址",
rich_text: { equals: slug },
},
}),
}
);
if (!response.ok) return null;
let data = await response.json();
// Step 3b:如果找不到,嘗試把 slug 當作 page ID 直接查詢
// (處理沒有自訂 slug 的文章,URL 會是 Notion page ID)
if (data.results.length === 0) {
try {
const pageResponse = await fetch(
`https://api.notion.com/v1/pages/${slug}`,
{
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
"Notion-Version": "2022-06-28",
},
}
);
if (pageResponse.ok) {
data = { results: [await pageResponse.json()] };
} else {
return null;
}
} catch {
return null;
}
}
if (data.results.length === 0) return null;
const page = data.results[0];
const props = page.properties;
// Step 3c:用 notion-to-md 將 Notion Blocks 轉成 Markdown
const mdblocks = await n2m.pageToMarkdown(page.id);
const mdString = n2m.toMarkdownString(mdblocks);
const markdownContent = mdString.parent;
// 映射其餘 properties(同 Step 2 的邏輯)
const title = props.名稱?.title?.[0]?.plain_text || "Untitled";
const date = props.發布日期?.date?.start;
const category = props.分類?.multi_select?.[0]?.name;
let coverImage: string | null = null;
if (page.cover?.type === "external") {
coverImage = page.cover.external.url;
} else if (page.cover?.type === "file") {
coverImage = page.cover.file.url;
} else if (props.封面?.files?.length > 0) {
const fileObj = props.封面.files[0];
coverImage = fileObj.type === "external"
? fileObj.external.url
: fileObj.file.url;
}
return {
id: page.id,
slug: props.網址?.rich_text?.[0]?.plain_text || page.id,
content: markdownContent,
title,
date,
category,
coverImage,
author: {
name: "Your Name",
avatar: "/avatar.jpg",
role: "Author",
},
};
};
- Slug fallback 機制很重要:如果作者忘了填「網址」欄位,系統會用 Notion page ID 當 slug,不會壞掉
n2m.toMarkdownString() 回傳的是 { parent: string } 結構,要取 .parent
notion-to-md 會自動處理大部分 Notion Block 類型(段落、標題、列表、程式碼、圖片等),但少數進階 Block(如 synced block、database view)可能需要額外處理
Step 4:Markdown 渲染與進階處理
目的:將 Markdown 字串渲染成 React 元件,並處理圖片畫廊偵測、Notion 內部連結轉換、自動生成 heading anchor。
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import type { ReactNode } from "react";
// --- 工具函式 ---
// 將 heading 文字轉成 URL-friendly slug(支援中文)
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[\s]+/g, "-")
.replace(/[^\w\u4e00-\u9fff-]/g, "")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
}
// 從 React children 提取純文字
function getTextFromChildren(children: ReactNode): string {
if (typeof children === "string") return children;
if (typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(getTextFromChildren).join("");
if (children && typeof children === "object" && "props" in children) {
return getTextFromChildren((children as any).props?.children);
}
return "";
}
// 判斷是否為 Notion 內部連結(TOC 錨點或 block reference)
function isNotionInternalLink(href: string): boolean {
const patterns = [
/notion\.so/i,
/notion\.site/i,
/#[a-f0-9]{32}$/i,
/[?&]v=[a-f0-9]/i,
/\/[a-f0-9]{32}[?#]/i,
/\/[a-f0-9]{32}$/i,
];
return patterns.some((p) => p.test(href));
}
// --- 圖片畫廊偵測 ---
// 偵測 Markdown 中連續 2-4 張圖片,替換成畫廊佔位符
function processContentForGalleries(
content: string,
title: string
): { processed: string; galleries: Array<Array<{ alt: string; url: string }>> } {
const galleries: Array<Array<{ alt: string; url: string }>> = [];
let imageCount = 0;
// 為沒有 alt text 的圖片自動生成
let processed = content.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => {
if (!alt) {
imageCount++;
return ``;
}
return match;
});
// 偵測連續圖片並替換成畫廊佔位符
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)/;
const lines = processed.split("\n");
const result: string[] = [];
let i = 0;
while (i < lines.length) {
if (imagePattern.test(lines[i].trim())) {
const imageGroup: Array<{ alt: string; url: string }> = [];
let j = i;
while (j < lines.length) {
const current = lines[j].trim();
const match = current.match(imagePattern);
if (match) {
imageGroup.push({ alt: match[1], url: match[2] });
j++;
} else if (current === "") {
j++;
} else {
break;
}
}
if (imageGroup.length >= 2 && imageGroup.length <= 4) {
galleries.push(imageGroup);
result.push(`[GALLERY_PLACEHOLDER_${galleries.length - 1}]`);
result.push("");
i = j;
} else {
result.push(lines[i]);
i++;
}
} else {
result.push(lines[i]);
i++;
}
}
return { processed: result.join("\n"), galleries };
}
// --- 渲染元件 ---
function ArticleContent({ content, title }: { content: string; title: string }) {
const { processed, galleries } = processContentForGalleries(content, title);
// Heading 元件:自動加上 id 作為錨點
const createHeading = (Tag: "h1" | "h2" | "h3") => {
return ({ children }: { children?: ReactNode }) => {
const text = getTextFromChildren(children);
const id = slugify(text);
return <Tag id={id}>{children}</Tag>;
};
};
return (
<article className="prose max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
h1: createHeading("h1"),
h2: createHeading("h2"),
h3: createHeading("h3"),
// Notion 內部連結 → 頁面內錨點
a: ({ href, children, ...props }) => {
let finalHref = href || "#";
if (href && isNotionInternalLink(href)) {
finalHref = `#${slugify(getTextFromChildren(children))}`;
}
return <a href={finalHref} {...props}>{children}</a>;
},
// 隱藏無效圖片
img: ({ src, alt, ...props }) => {
if (!src || src === "null" || src === "undefined") return null;
return (
<img
src={src}
alt={alt || ""}
{...props}
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
);
},
// 畫廊佔位符替換
p: ({ children, ...props }) => {
const text = typeof children === "string" ? children : "";
const match = text.match(/\[GALLERY_PLACEHOLDER_(\d+)\]/);
if (match) {
const idx = parseInt(match[1]);
if (galleries[idx]) {
// 這裡替換成你的圖片畫廊元件
return <YourImageGallery images={galleries[idx]} />;
}
}
return <p {...props}>{children}</p>;
},
}}
>
{processed}
</ReactMarkdown>
</article>
);
}
rehype-raw 是必要的,因為 notion-to-md 有時會輸出 HTML 標籤(例如 <br>、<aside> 等)
- 圖片畫廊偵測邏輯:連續 2-4 張圖片(中間可以有空行)會被轉成畫廊,單張圖片正常渲染
isNotionInternalLink 處理的是 Notion 目錄(TOC)連結。Notion 匯出的 TOC 連結會指向 notion.so 或帶有 32 字元 block ID 的 hash,這裡把它們轉成頁面內的 heading anchor
slugify 保留了中文字元(\u4e00-\u9fff),如果你的文章是純英文可以簡化
Step 5:Next.js 路由整合(ISR + Static Params + SEO)
目的:設定 Next.js App Router 的頁面,包含 ISR 快取、靜態路徑生成、動態 metadata。
// --- /blog/page.tsx(列表頁)---
import { getPublishedPosts } from "@/lib/notion";
export const revalidate = 60; // ISR:每 60 秒重新驗證
export default async function BlogPage() {
const posts = await getPublishedPosts();
if (!posts || posts.length === 0) {
return <div>尚無文章</div>;
}
const featuredPost = posts[0]; // 最新一篇作為精選
const otherPosts = posts.slice(1); // 其餘用 grid 排列
return (
<div>
<FeaturedPostCard post={featuredPost} />
<div className="grid md:grid-cols-3 gap-8">
{otherPosts.map((post) => (
<BlogPostCard key={post.slug} post={post} />
))}
</div>
</div>
);
}
// --- /blog/[slug]/page.tsx(單篇文章頁)---
import { getPostBySlug, getPublishedPosts } from "@/lib/notion";
import { notFound } from "next/navigation";
// 預先產生所有已發佈文章的路徑
export async function generateStaticParams() {
const posts = await getPublishedPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// 動態 SEO metadata
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return { title: "文章不存在" };
const description = post.content?.slice(0, 160).replace(/[#*`]/g, "") || "";
return {
title: post.title,
description,
openGraph: {
title: post.title,
description,
type: "article",
publishedTime: post.date,
images: post.coverImage ? [{ url: post.coverImage }] : [],
},
};
}
export const revalidate = 600; // ISR:每 10 分鐘重新驗證
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
// 相關文章:取所有文章中排除自己的前 2 篇
const allPosts = await getPublishedPosts();
const relatedPosts = allPosts.filter((p) => p.slug !== post.slug).slice(0, 2);
return (
<div>
{/* JSON-LD 結構化資料 */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
image: post.coverImage ? [post.coverImage] : [],
datePublished: post.date,
dateModified: post.date,
author: [{
"@type": "Person",
name: post.author.name,
}],
}),
}}
/>
<h1>{post.title}</h1>
<span>{post.date}</span>
{post.coverImage && <img src={post.coverImage} alt={post.title} />}
<ArticleContent content={post.content || ""} title={post.title} />
{/* 相關文章 */}
{relatedPosts.map((p) => (
<a key={p.slug} href={`/blog/${p.slug}`}>{p.title}</a>
))}
</div>
);
}
revalidate 值的選擇:列表頁 60 秒(新文章能快速出現)、文章頁 600 秒(內容不常改動)
generateStaticParams 讓 Next.js 在 build 時預先產生所有文章頁面,之後靠 ISR 更新
params 在 Next.js 15+ 是 Promise,需要 await
5. 快速開始
前提條件
實作步驟
-
安裝依賴
npm install @notionhq/client notion-to-md react-markdown remark-gfm rehype-raw
# 選用:圖片畫廊
npm install embla-carousel-react
-
設定環境變數
# .env.local
NOTION_TOKEN=secret_xxxxxxxxxxxxx
NOTION_BLOG_DATABASE_ID=your_database_id_here
-
建立資料層 (lib/notion.ts)
- 複製 Step 1-3 的程式碼
- 修改 property 名稱對應你的 Notion Database schema
-
建立路由
app/blog/page.tsx — 文章列表頁(Step 5)
app/blog/[slug]/page.tsx — 單篇文章頁(Step 5)
-
建立渲染元件 (components/ArticleContent.tsx)
- 複製 Step 4 的程式碼
- 根據你的設計系統調整 Tailwind class
-
測試驗證
6. 不同技術棧的調整方式
如果你的技術棧跟上面不完全一樣,這邊整理了需要調整的部分。
高度通用的部分(直接用)
- Notion API 呼叫邏輯(原生 fetch,不依賴框架)
notion-to-md 轉換(純 Node.js)
- Property 映射邏輯(調整 property 名稱即可)
- Slug fallback 機制
- 圖片畫廊偵測演算法(純字串處理)
需要客製化的部分
| 部分 | 原實作 | 新專案需要調整的地方 |
|---|
| Property 名稱 | 繁體中文(名稱、網址、描述...) | 改成你的 Database property 名稱 |
| 狀態過濾值 | 已發佈 | 改成你的 Status option 名稱 |
| Author 資訊 | 硬編碼 | 改成從 Notion property 讀取,或你的 User 系統 |
| ISR revalidate | 60s / 600s | 根據你的更新頻率調整 |
| Markdown 樣式 | Tailwind prose | 根據你的設計系統調整 |
技術棧替換建議
- 如果你用 Pages Router 而非 App Router:把
generateStaticParams 改成 getStaticPaths,generateMetadata 改成在 component 內用 <Head>,revalidate 放在 getStaticProps 的 return 裡
- 如果你用 Astro / Remix 而非 Next.js:資料層(
notion.ts)完全通用,只需調整路由和渲染方式
- 如果你不需要圖片畫廊:移除
processContentForGalleries 和 embla-carousel-react,直接把 Markdown 丟給 ReactMarkdown
- 如果你想用 Notion SDK 而非原生 fetch:可以把
fetch 呼叫換成 notion.databases.query() 和 notion.pages.retrieve(),API 回傳格式一樣
結語
這套架構跑了幾個禮拜,比預期穩定不少。Notion 那邊寫完改個狀態,網站幾十秒內就更新了,團隊也不需要學新工具。如果你也在找一個低摩擦的部落格方案,這篇整理的程式碼應該能幫你省掉不少摸索的時間。
© 2026 GEEKAZ. All Rights Reserved.