1. 项目概述:一个基于Notion的现代化网站生成器
如果你正在寻找一个能让你用Notion作为内容管理系统(CMS),快速搭建起一个兼具美观与性能的个人博客、作品集或文档站点的方案,那么nextjs-notion-starter-kit这个开源项目绝对值得你花时间深入研究。它不是一个玩具,而是一个经过实战检验、架构清晰的生产力工具。简单来说,它打通了Notion这个我们无比熟悉的笔记/文档工具与Next.js这个前沿的React框架之间的桥梁,让你能像管理笔记一样轻松更新网站内容,同时享受Next.js带来的服务端渲染(SSR)、静态站点生成(SSG)等现代化Web开发特性所带来的极致性能与SEO优势。
我第一次接触这个项目,是因为厌倦了传统CMS的笨重和静态站点生成器(如Jekyll、Hugo)需要本地编译、提交代码的繁琐流程。我希望我的内容创作能回归纯粹——在一个地方书写,然后自动、实时地同步到我的网站上。Notion恰好满足了我对“自由编辑”和“结构化数据”的所有幻想,而nextjs-notion-starter-kit则完美地将这个幻想变成了现实。它不仅仅是一个“模板”,更是一套完整的工程化解决方案,涵盖了从数据获取、页面渲染、样式定制到部署上线的全链路。接下来,我将带你深入拆解这个项目,从设计思想到每一行关键代码,分享我从中获得的经验与踩过的坑。
2. 核心架构与设计哲学解析
2.1 为什么是Notion + Next.js?
这个组合的巧妙之处在于,它精准地捕捉了当代个人开发者和小型团队的核心痛点:内容生产与发布流程的脱节。传统的流程可能需要你在Markdown编辑器、Git、CI/CD工具和服务器之间反复横跳。而Notion作为内容源,带来了革命性的改变。
Notion的核心优势:
- 极致的编辑体验:富文本、拖拽、数据库、看板视图,这些功能让内容创作和管理变得直观而高效。你可以用数据库来管理博客文章(包含标题、标签、日期、状态等属性),用看板来规划内容排期,这远比维护一堆Markdown文件要直观得多。
- 强大的API:Notion官方提供了完善的API,可以以编程方式读取数据库(Database)和页面(Page)的内容,包括块级结构(Block)、属性(Properties)等。这为外部应用消费内容提供了可能。
- 实时协作与版本历史:对于团队内容创作,这是无可替代的优势。任何修改都有历史记录,且可以多人同时编辑。
Next.js的核心优势:
- 混合渲染模式:它同时支持SSG(构建时生成静态HTML)和SSR(请求时生成HTML)。对于博客这类内容更新有一定频率但不需要实时性的站点,SSG是绝佳选择,它能生成最快的页面。而对于需要个性化或实时数据的页面,SSR可以胜任。
- 基于文件系统的路由:
pages或app目录下的文件结构自动映射为路由,简化了开发。 - 出色的开发者体验(DX):热重载、TypeScript开箱即用、丰富的插件生态。
- 优异的性能:自动代码分割、图片优化、字体优化等特性,让网站性能指标(如LCP、FID)非常出色。
nextjs-notion-starter-kit所做的,就是通过Notion API将Notion中的数据“拉取”过来,然后利用Next.js的SSG能力,在构建时将这些数据渲染成静态页面。当你在Notion中更新内容后,触发一个重新构建的钩子(例如通过Vercel的Deploy Hooks),网站就自动更新了。这实现了“内容在Notion,发布全自动”的梦想工作流。
2.2 项目整体架构拆解
这个项目的代码结构清晰,遵循了Next.js的最佳实践,并抽象出了几个关键层:
nextjs-notion-starter-kit/ ├── lib/ # 核心逻辑层 │ ├── notion.ts # Notion API客户端封装、数据获取逻辑 │ └── utils.ts # 通用工具函数(日期格式化、文本处理等) ├── components/ # React组件层 │ ├── NotionPage.tsx # 核心:将Notion Block渲染为React组件的渲染器 │ ├── NotionText.tsx # 处理Notion中的富文本样式 │ └── ... (其他UI组件) ├── pages/ # 页面层 (或 app/ 目录,取决于Next.js版本) │ ├── index.tsx # 首页,通常展示文章列表 │ ├── [slug].tsx # 动态路由,用于渲染具体的文章页面 │ └── api/ # API路由(用于触发增量更新等) ├── public/ # 静态资源 ├── styles/ # 样式文件 (Tailwind CSS) ├── types/ # TypeScript类型定义 └── notion.config.ts # 项目配置(Notion数据库ID、站点信息等)数据流的核心:
- 配置:在
notion.config.ts中,你需要填入你的Notion集成(Integration)的密钥(NOTION_TOKEN)和作为内容源的数据库ID(NOTION_DATABASE_ID)。 - 获取:在
lib/notion.ts中,项目封装了函数(如getDatabase,getPage,getBlocks)来调用Notion API。这些函数会处理认证、分页、错误重试等细节。 - 转换:获取到的原始Notion数据(尤其是Block数组)需要被转换为一棵适合React渲染的树形结构。
NotionPage组件是这个过程的枢纽,它递归地遍历Block,根据Block的类型(type)调用对应的渲染组件(如HeadingBlock,ParagraphBlock,ImageBlock)。 - 渲染:在页面文件(如
pages/[slug].tsx)中,使用getStaticProps和getStaticPaths这两个Next.js的数据获取函数。getStaticPaths获取所有文章的slug(通常从数据库的属性中提取),生成所有可能的静态页面路径。getStaticProps则根据当前slug获取对应的页面数据和块数据,并传递给页面组件进行SSG渲染。 - 样式:项目通常集成Tailwind CSS,样式通过组合实用类(Utility Classes)的方式应用到各个渲染组件上,保持了高度的可定制性。
注意:Notion API有速率限制。在
getStaticProps中大量获取页面和块数据时,如果文章很多,可能会触发限制。项目通常通过缓存策略(如使用lru-cache)或增量构建来缓解此问题。
3. 关键配置与核心代码深度剖析
3.1 Notion集成配置与安全实践
第一步,也是最关键的一步,是正确配置Notion集成。这不仅仅是拿到Token和ID那么简单,其中有很多安全性和功能性的细节。
创建Notion集成:
- 访问
https://www.notion.so/my-integrations。 - 点击 “New integration”, 填写名称,选择关联的工作区。
- 关键权限设置:对于只读的博客,通常只需要勾选 “Read content” 和 “Read user information” 即可。切勿授予“Update content”或“Insert content”权限,除非你的网站需要向Notion回写数据,这能最大程度保证内容安全。
- 创建后,复制 “Internal Integration Token”, 这就是你的
NOTION_TOKEN。
获取数据库ID并分享给集成:
- 在你的Notion工作区创建一个数据库(Database),这将是你的“文章库”。设计好属性,如
Title(标题)、Slug(唯一标识)、Published(复选框,用于控制是否发布)、Date(日期)、Tags(多选)等。 - 打开这个数据库页面,点击右上角的 “...” -> “Add connections”, 搜索并添加你刚刚创建的集成。
- 数据库的ID可以从其URL中提取。例如,URL为
https://www.notion.so/your-workspace/a1b2c3d4e5f6..., 那么a1b2c3d4e5f6就是数据库ID。这就是你的NOTION_DATABASE_ID。
环境变量管理: 绝对不要将NOTION_TOKEN和NOTION_DATABASE_ID硬编码在代码中或提交到Git仓库。必须使用环境变量。
# .env.local 文件 (本地开发) NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx NOTION_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx在部署平台(如Vercel、Netlify)上,也需要在项目设置中配置这些环境变量。
3.2 Notion API数据获取层详解
lib/notion.ts是这个项目的大脑。我们来看几个核心函数。
数据库查询:
import { Client } from ‘@notionhq/client’; import { cache } from ‘react’; // Next.js 14+ 的缓存API const notion = new Client({ auth: process.env.NOTION_TOKEN }); // 使用Next.js缓存,避免在同一个渲染周期内重复请求 export const getDatabase = cache(async () => { const response = await notion.databases.query({ database_id: process.env.NOTION_DATABASE_ID!, filter: { and: [ { property: ‘Published’, // 假设有一个“Published”复选框属性 checkbox: { equals: true, }, }, ], }, sorts: [ { property: ‘Date’, // 按日期排序 direction: ‘descending’, }, ], }); return response.results; });这里使用了filter来只获取已发布(Published为真)的文章,并用sorts按日期降序排列。cache是Next.js 14引入的API,能智能地缓存函数结果,在同一个请求周期内避免重复调用,提升性能。
获取页面内容和块:
export const getPage = async (pageId: string) => { return await notion.pages.retrieve({ page_id: pageId }); }; export const getBlocks = async (blockId: string) => { const blocks = []; let cursor: string | undefined; // Notion API返回的块列表可能分页,需要循环获取 do { const { results, next_cursor } = await notion.blocks.children.list({ block_id: blockId, start_cursor: cursor, }); blocks.push(...results); cursor = next_cursor ?? undefined; } while (cursor); return blocks; };getBlocks函数展示了如何处理Notion API的分页。一个复杂的页面可能包含成百上千个块,API一次只返回有限数量(默认100),通过next_cursor可以获取下一页。
3.3 核心渲染组件:NotionPage
这是项目的灵魂所在,负责将扁平的Block列表渲染成嵌套的React组件树。其核心逻辑是递归。
// components/NotionPage.tsx 简化版 const NotionPage = ({ blocks }: { blocks: any[] }) => { return ( <div> {blocks.map((block) => ( <NotionBlockRenderer key={block.id} block={block} /> ))} </div> ); }; const NotionBlockRenderer = ({ block }: { block: any }) => { const { type, id } = block; const value = block[type]; switch (type) { case ‘paragraph’: return <ParagraphBlock value={value} id={id} />; case ‘heading_1’: case ‘heading_2’: case ‘heading_3’: return <HeadingBlock type={type} value={value} id={id} />; case ‘image’: return <ImageBlock value={value} id={id} />; case ‘bulleted_list_item’: case ‘numbered_list_item’: // 列表项需要特殊处理,因为它们可能包含嵌套的子块 return <ListItemBlock type={type} value={value} id={id} />; case ‘code’: return <CodeBlock value={value} id={id} />; // ... 处理更多Block类型 default: console.warn(`Unsupported block type: ${type}`); return null; } };每个具体的块渲染组件(如ParagraphBlock)则负责解析该类型块特有的数据。例如,ParagraphBlock需要处理富文本数组,其中可能包含加粗、斜体、链接、颜色等样式。
// components/NotionText.tsx const NotionText = ({ text }: { text: any[] }) => { if (!text) return null; return text.map((value, index) => { const { annotations: { bold, italic, code, strikethrough, underline, color }, text, } = value; const Tag = code ? ‘code’ : ‘span’; const style: React.CSSProperties = {}; if (color !== ‘default’) style.color = `var(--notion-${color})`; // 可以映射到CSS变量 return ( <Tag key={index} style={style} className={clsx( bold && ‘font-bold’, italic && ‘italic’, strikethrough && ‘line-through’, underline && ‘underline’, code && ‘font-mono bg-gray-100 px-1 rounded’ )} > {text.link ? ( <a href={text.link.url} className=“text-blue-600 hover:underline”> {text.content} </a> ) : ( text.content )} </Tag> ); }); };这里使用了clsx库来条件组合Tailwind CSS类名,是一种非常高效的做法。
4. 高级功能实现与定制化指南
4.1 实现增量静态再生(ISR)与实时预览
虽然SSG速度极快,但内容更新需要重新构建整个站点。对于更新频繁的站点,这可能不够理想。Next.js的增量静态再生(ISR)提供了完美的解决方案。
ISR实现: 在getStaticProps中,除了返回props, 还可以返回一个revalidate字段(单位:秒)。
export async function getStaticProps({ params }) { const post = await getPageBySlug(params.slug); const blocks = await getBlocks(post.id); return { props: { post, blocks }, revalidate: 60, // 每隔60秒,允许下一个请求重新生成此页面 }; }这样,页面在构建后仍然是静态的,但每隔60秒,如果有新的请求到来,Next.js会在后台重新运行getStaticProps获取最新数据,生成新的页面,并替换旧版本。用户访问的始终是快速的静态页面,而内容可以在后台更新。
实时预览(Preview Mode): 有时,在Notion中编辑后,你想立即在网站上看到效果,而不是等待ISR周期或重新构建。Next.js的预览模式可以实现这一点。
- 创建一个API路由,例如
pages/api/preview.ts, 它设置预览模式Cookie。 - 在
getStaticProps中,检查预览模式。如果启用,则绕过缓存,直接请求最新数据。 - 在Notion中,你可以设置一个“立即预览”按钮,点击后调用这个API并重定向到文章页面。
这为内容编辑者提供了所见即所得的体验。
4.2 深度样式定制与主题系统
项目默认使用Tailwind CSS,这给了我们极大的定制自由。但直接修改组件类名可能不够系统化。建议建立一套设计令牌(Design Tokens)或主题变量。
步骤一:扩展Tailwind配置在tailwind.config.js中,定义你的颜色、字体、间距等主题变量。
module.exports = { theme: { extend: { colors: { ‘primary’: ‘#0070f3’, ‘secondary’: ‘#ff4081’, ‘notion-gray’: ‘#f7f6f3’, // 模仿Notion的背景色 }, fontFamily: { ‘sans’: [‘Inter’, ‘system-ui’, ‘sans-serif’], // Notion使用的字体 }, }, }, }步骤二:创建可复用的组件变体不要在每个NotionBlockRenderer的子组件里写死类名。可以创建一套组件映射。
// components/ui/typography.tsx export const H1 = ({ children, className }) => ( <h1 className={`text-4xl font-bold mt-8 mb-4 ${className}`}>{children}</h1> ); export const H2 = ({ children, className }) => ( <h2 className={`text-3xl font-semibold mt-6 mb-3 ${className}`}>{children}</h2> ); // ... 其他文本组件然后在HeadingBlock中直接使用<H1>或<H2>。
步骤三:支持暗色模式利用Tailwind CSS的dark:变体。
<body class=“bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100”>在组件中:
<p className=“text-gray-700 dark:text-gray-300”>...</p>你可以在站点头部添加一个切换按钮,用JavaScript切换html元素上的class=“dark”。
4.3 SEO与性能优化实战
一个用Notion驱动的网站,在SEO和性能上完全可以做到顶尖水平。
SEO优化:
- Next.js Head组件:在每个页面(
pages/[slug].tsx)中,使用next/head动态设置<title>,<meta name=“description”>, 以及Open Graph标签(用于社交媒体分享)。import Head from ‘next/head’; export default function PostPage({ post }) { return ( <> <Head> <title>{post.properties.Title.title[0].plain_text} | 我的博客</title> <meta name=“description” content={post.properties.Excerpt?.rich_text[0]?.plain_text || ‘’} /> <meta property=“og:title” content={post.properties.Title.title[0].plain_text} /> <meta property=“og:image” content={post.cover?.external?.url || post.cover?.file?.url || ‘/default-og.png’} /> </Head> {/* 页面内容 */} </> ); } - 生成站点地图(Sitemap):在
pages/sitemap.xml.js中创建一个API路由,动态生成包含所有文章链接的XML站点地图。 - 规范链接(Canonical URL):在Head中设置
<link rel=“canonical” href={当前页面完整URL} />, 避免重复内容。
性能优化:
- Next.js Image组件:Notion中的图片URL是外链。务必使用
next/image组件来优化。
这会自动实现图片的懒加载、WebP格式转换、尺寸优化等。import Image from ‘next/image’; <Image src={imageUrl} alt={altText} width={1200} // 指定宽度和高度以优化布局偏移(CLS) height={630} layout=“responsive” // 或 “intrinsic”, “fixed” priority={true} // 对于首屏关键图片,可以预加载 /> - 字体优化:使用
next/font来托管和优化自定义字体(如Inter),消除布局偏移并提升加载速度。 - 代码分割:Next.js默认已做得很好了。确保你的大型第三方库(如某些图表库)被动态导入(
dynamic import)。 - 缓存策略:对于Notion API的响应,可以在
getStaticProps中使用内存缓存(如LRU Cache)或部署平台提供的边缘缓存(如Vercel的ISR),减少API调用次数和延迟。
5. 部署、监控与常见问题排查
5.1 部署平台选择与配置
首选Vercel:作为Next.js的创建者,Vercel提供了最无缝的体验。连接你的Git仓库后,它会自动检测Next.js项目并进行优化构建。
- 环境变量:在Vercel项目设置的
Environment Variables中填入NOTION_TOKEN和NOTION_DATABASE_ID。 - 构建命令:通常为
npm run build或next build。 - 输出目录:Next.js项目不需要指定。
- 触发构建:你可以配置一个GitHub Action或使用Vercel的Deploy Hooks。更优雅的方式是使用Notion的Webhooks(需通过第三方服务中转,如Zapier或Make.com),当数据库有更新时,自动调用Vercel的Deploy Hook URL触发重新构建。
备选Netlify:配置类似,同样支持环境变量和自动部署。Netlify的Forms和Functions功能也很强大。
静态导出(Static Export):如果你希望部署到任何静态托管服务(如GitHub Pages, Cloudflare Pages),可以运行next export命令。但请注意,这会生成纯静态HTML,将无法使用ISR、API路由等需要Node.js运行时的功能。对于纯内容博客,这通常是可行的。
5.2 监控与日志
网站上线后,监控是必不可少的。
- 错误监控:集成Sentry或LogRocket。在
_app.tsx中初始化Sentry,可以捕获前端React错误和后端Next.js API路由的错误。 - 性能监控:使用Vercel Analytics、Google Lighthouse CI或商业方案如SpeedCurve,持续监控核心Web指标(LCP, FID, CLS)。
- API健康检查:Notion API偶尔会有不稳定。可以设置一个简单的Cron Job(例如使用GitHub Actions),定期调用一个检查用的API路由,该路由尝试获取一篇已知文章,如果失败则发送告警(如通过邮件、Slack)。
5.3 常见问题与解决方案实录
以下是我在多次使用和部署nextjs-notion-starter-kit过程中遇到的一些典型问题及解决方法。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
构建失败,错误信息包含Notion API error: object_not_found | 1.NOTION_DATABASE_ID错误。2. Notion集成(Integration)未被分享到该数据库。 | 1. 仔细核对数据库ID,确保从正确的URL提取。 2. 进入Notion数据库页面,点击“Share”或“Add connections”,确保你的集成已被添加。 |
| 页面能构建,但文章内容空白或格式错乱 | 1. Notion页面内容结构复杂,有未支持的Block类型。 2. getBlocks函数分页逻辑有误,未获取全部块。3. 样式(CSS)未正确加载或冲突。 | 1. 在NotionBlockRenderer的default分支中添加日志,查看不支持的类型,并考虑实现或忽略它。2. 检查 getBlocks中的分页循环逻辑,确保cursor被正确处理。3. 检查浏览器控制台是否有CSS加载错误,确保Tailwind CSS已正确编译引入。 |
| 本地开发正常,部署后图片不显示 | 1. Notion图片链接是内部链接,需要Notion登录才能访问。 2. 部署环境(如Vercel)的IP被Notion限制。 | 1.这是最常见的问题!Notion的图片链接(file类型)是临时的,且受权限保护。解决方案是:在getBlocks获取数据后,遍历所有image类型的块,将其file.url通过一个代理API路由进行中转,或者使用next/image的loader属性配置一个自定义图片优化服务(如将图片先上传到Cloudinary或Imgix)。社区有相关方案,需要额外处理。 |
| ISR不生效,页面内容不更新 | 1.revalidate值设置过大。2. 部署平台的ISR支持问题。 3. 页面访问量过低,始终未触发再生。 | 1. 将revalidate设置为一个合理的值,如60(1分钟)。2. 确保部署在支持ISR的平台(Vercel, Netlify等)。 3. 可以手动访问页面并加上 ?force-reload=true之类的参数,然后在代码中监听此参数强制刷新数据。 |
| 网站打开速度慢,尤其是图片多时 | 1. 图片未优化,尺寸过大。 2. 未使用 next/image。3. 字体文件过大或未优化。 | 1. 强制使用next/image组件。2. 配置 next/image的loader指向一个图片CDN(如Vercel自身或Cloudinary),进行自动优化。3. 使用 next/font加载字体,并只加载需要的字重。 |
一个关于图片代理的实操心得: 直接使用Notion的图片URL在外网是不可靠的。我采用的稳定方案是:在Next.js中创建一个API路由/api/proxy-image?url=...。这个路由的任务是:
- 接收Notion的图片URL。
- 使用服务器的环境变量(
NOTION_TOKEN)来获取图片数据(因为服务器有权限)。 - 将图片数据流式传输(stream)回客户端。 然后,在图片组件的
src中,不使用原始Notion URL,而是使用这个代理路由的地址。虽然增加了一次服务器中转,但保证了图片的稳定可访问性,并且仍然可以利用next/image进行格式和尺寸优化。
另一个关于数据库筛选的坑: 如果你的Notion数据库有很多属性,并且在API查询中使用了复杂的筛选器(filter),可能会遇到查询超时或返回结果不完整。Notion API对复杂查询的支持有限。我的经验是:尽量保持筛选条件简单。如果需要复杂查询,可以考虑在获取全部数据后,在Next.js服务端内存中进行过滤和排序,但这只适用于数据量不大的情况。对于大型数据库,更优的设计是直接在Notion中利用视图(View)的筛选和排序功能,然后通过API获取特定视图下的页面,这相当于把过滤逻辑交给了Notion。
这个项目就像一个精密的乐高套装,提供了所有核心模块。你的创造力决定了最终建筑的样貌。你可以把它扩展成一个多作者博客平台、一个产品文档中心、甚至是一个简单的电商产品目录。其核心价值在于解放了内容创作者,让技术栈成为默默无闻的基石,而非需要反复打理的盆景。当你下次在Notion中流畅地写完一篇文档,并看到它自动、完美地呈现在你自己的网站上时,你会感受到这种工作流带来的巨大愉悦感。