ACQUIRING IMAGE…

LOG ENTRYCAT / 教程

使用 Notion + Next.js 搭建博客

分享使用 Notion 作为 CMS 配合 Next.js 和 react-notion-x 搭建博客的完整流程和代码实现。

经验分享工具推荐

LOADING DATABASE
最近发现 Notion 作为内容管理工具非常好用,于是我决定把博客搬到 Notion 上,并通过 Next.js + react-notion-x 来实现页面渲染。本文会分享完整流程,包括数据库配置、代码实现以及内容渲染。

1. 在 Notion 中创建数据库

首先需要在 Notion 中建立一个数据库来存放博客文章。
我为数据库添加了以下属性:
  • Title:文章标题
  • Status:文章状态(如:构思中、已发布)
  • Categories:分类(单选)
  • Tags:标签(多选)
  • CreatedAt:创建时间
  • URLName:文章的唯一标识
  • Digest:文章摘要
配置好数据库后,就可以直接在 Notion 中编写文章了。

2. 安装依赖

在 Next.js 项目中安装 Notion 相关依赖:
pnpm add @notionhq/client notion-client react-notion-x

3. Notion API 封装

lib/notion.ts 中封装操作 Notion 的 API,包括分页查询、获取所有文章、解析 Markdown 和 RecordMap。
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Client } from '@notionhq/client';
import { NotionAPI } from 'notion-client';

// 初始化客户端
export const notion = new Client({ auth: process.env.NOTION_TOKEN });
export const NOTION_DATABASE_ID = process.env.NOTION_DATABASE_ID;
const notionApi = new NotionAPI({
  activeUser: process.env.NOTION_ACTIVE_USER,
  authToken: process.env.NOTION_TOKEN_V2,
});

// ... 这里省略缓存与 transform 函数

// 获取分页博客
export async function getPaginatedBlogs(pageSize: number = 10, startCursor?: string) {
  const response = await notion.databases.query({
    database_id: NOTION_DATABASE_ID,
    filter: { property: 'Status', status: { equals: '已发布' } },
    sorts: [{ property: 'CreatedAt', direction: 'descending' }],
    page_size: pageSize,
    start_cursor: startCursor,
  });
  return response.results.map(transformNotionPageToBlog);
}

// 获取所有博客(用于计算总数和静态生成)
export async function getAllBlogs(): Promise<NotionBlog[]> {
  const allBlogs: NotionBlog[] = [];
  let hasMore = true;
  let startCursor: string | undefined;

  while (hasMore) {
    const result = await getPaginatedBlogs(100, startCursor);
    allBlogs.push(...result.data);
    hasMore = result.hasMore;
    startCursor = result.nextCursor || undefined;
  }

  return allBlogs;
}

// 使用 notion-client 获取完整的 recordMap,用于 react-notion-x 渲染
export async function getPageRecordMap(pageId: string) {
  try {
    const cleanId = pageId.replace(/-/g, '');

    // 添加超时控制,最多等待8秒
    const timeoutPromise = new Promise((_, reject) => {
      setTimeout(() => reject(new Error('RecordMap获取超时')), 8000);
    });

    const recordMapPromise = notionApi.getPage(cleanId);
    
    const recordMap = await Promise.race([recordMapPromise, timeoutPromise]);

    return recordMap;
  } catch (error) {
    console.error(`[NotionRecordMap] RecordMap 获取失败`, {
      error: error instanceof Error ? error.message : String(error),
      pageId,
      cleanId: pageId.replace(/-/g, ''),
    });
    
    // 如果是超时错误,返回一个空的recordMap而不是抛出错误
    if (error instanceof Error && error.message.includes('超时')) {
      return {
        block: {},
        collection: {},
        collection_view: {},
        notion_user: {},
        signed_urls: {},
        preview_images: {},
      };
    }
    
    throw error;
  }
}

4. 内容渲染器

使用 react-notion-x 来渲染文章内容,并在页面中自定义博客标题、摘要、分类和标签。
'use client';

import { NotionRenderer } from 'react-notion-x';
import { useTheme } from 'next-themes';

const NotionContent = ({ recordMap, blog }) => {
  const { resolvedTheme } = useTheme();
  const isDark = resolvedTheme === 'dark';

  return (
    <div className="notion container mx-auto px-4 py-12 max-w-5xl">
      <NotionRendererpageTitle={<h1 className="text-4xl font-bold">{blog.title}</h1>}
        disableHeader={true}
        recordMap={recordMap}
        darkMode={isDark}
        fullPage={true}
        forceCustomImages
      />
    </div>
  );
};

export default NotionContent;
这样,每一篇 Notion 文章都能无缝渲染到博客中。

5. 环境变量配置

.env.local 中添加必要的配置:
NOTION_TOKEN=你的NotionAPIKey
NOTION_DATABASE_ID=你的数据库ID
NOTION_ACTIVE_USER=可选
NOTION_TOKEN_V2=可选
  • NOTION_TOKEN:从 Notion API 获取
  • NOTION_DATABASE_ID:数据库页面 URL 中的 ID