这是一份从空目录开始的实操教程。每一步都包含操作、代码、验证方法和常见错误。建议按顺序完成,不要跳过每节末尾的“完成标志”。
0. 开始前:准备账号和工具
你需要:
在终端中检查环境:
node --version
git --version
npm --versionNode 版本应为 v22.x 或更高。如果命令不存在,先安装 Node.js LTS 和 Git。
预计时间: 第一次操作约 1~2 小时。Notion、GitHub、Netlify 免费套餐足以运行个人博客;域名是唯一必需的可选付费项。
1. 理解最终的发布链路
先明确我们要搭建的不是“访问时从 Notion 读取”的动态站点,而是“构建时同步”的静态内容站:
Notion 写作
↓
同步脚本读取数据库和正文区块
↓
生成 content/notion-snapshot.json
↓
Next.js 根据快照生成首页和文章页
↓
Netlify 构建并发布到 CDN这样做有三个直接收益:
- 读者访问文章时不请求 Notion,打开速度更稳定;
- Notion 临时不可用时,线上旧文章仍能正常访问;
- 首页、归档、RSS、站点地图都可以复用同一份内容快照。
2. 创建本地项目
打开终端,执行:
npx create-next-app@latest notion-blog --typescript --eslint --app --no-src-dir --import-alias "@/*"
cd notion-blog
npm install安装正文渲染所需依赖:
npm install katex highlight.js
npm install -D vinext vite nitro @vitejs/plugin-react @vitejs/plugin-rsc把 package.json 的 scripts 修改为:
{
"scripts": {
"dev": "vinext dev",
"sync:notion": "node scripts/sync-notion.mjs",
"build": "node scripts/sync-notion.mjs && vinext build",
"build:netlify": "node scripts/sync-notion.mjs && vite build",
"start": "vinext start",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern .netlify"
}
}在项目根目录创建 vite.config.ts:
import vinext from "vinext";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vinext(), nitro({ preset: "netlify" })],
});启动空项目:
npm run dev浏览器打开终端显示的本地地址,通常是 http://localhost:3000。
3. 创建 Notion 博客数据库
3.1 新建数据库
在 Notion 中新建一个全页 Table Database,命名为“我的博客”。保留唯一的 Title 属性,并把它改名为 title。
按下表添加属性。字段名和选项值建议完全照抄,大小写也保持一致:
| 字段名 | Notion 类型 | 选项或示例 |
|---|---|---|
| title | Title | 我的第一篇文章 |
| slug | Text | my-first-post |
| status | Select | Draft、Published、Invisible |
| type | Select | Post |
| summary | Text | 文章摘要 |
| date | Date | 发布日期 |
| category | Select | 技术分享、随笔等 |
| tags | Multi-select | 开发、建站、Notion 等 |
| featured | Checkbox | 首页置顶 |
| Series | Select | 可选的系列名称 |
| Series Order | Number | 系列内部顺序 |
3.2 创建测试文章
新建一行并填写:
title: Hello Notion
slug: hello-notion
status: Published
type: Post
summary: 我的第一篇 Notion 博客文章
date: 今天
category: 技术分享
tags: Notion, 建站点开页面,在正文中添加:
- 一个二级标题;
- 两段文字;
- 一个代码块;
- 一张图片。
4. 创建 Notion Integration
- 打开 Notion Connections。
- 选择创建新的 Integration。
- 名称可以填
My Blog。 - 选择博客数据库所在的 Workspace。
- 只开启读取内容所需权限。
- 创建后复制 Internal Integration Token。
然后回到“我的博客”数据库:
- 点击右上角
•••; - 找到 Connections 或“连接”;
- 添加刚创建的
My BlogIntegration。
仅仅创建 Integration 不够,数据库还必须显式共享给它。
4.1 获取 Data Source ID
打开数据库,复制浏览器地址。新版 Notion API 使用 Data Source ID,而不是旧教程中常见的 Database ID。
最稳妥的方法是调用 Notion API 获取数据库信息,或在 Notion 开发者页面查看 Data Sources。最终得到一段 UUID,例如:
1e34cfb3-d63d-8143-9a2a-000b424944ba不要使用包含 ?v= 的视图 ID,也不要把整个 Notion URL 当成 Data Source ID。
5. 配置本地环境变量
在项目根目录创建 .env.local:
NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxx
NOTION_ZH_DATA_SOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
NEXT_PUBLIC_SITE_URL=http://localhost:3000创建 .env.example,只保留变量名,不放真实秘密:
NOTION_TOKEN=
NOTION_ZH_DATA_SOURCE_ID=
NEXT_PUBLIC_SITE_URL=
NETLIFY_BUILD_HOOK=
NOTION_WEBHOOK_SECRET=
NEXT_PUBLIC_GISCUS_REPO=
NEXT_PUBLIC_GISCUS_REPO_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=Announcements
NEXT_PUBLIC_GISCUS_CATEGORY_ID=确认 .gitignore 包含:
.env
.env.local
.env.*.local6. 创建内容快照和同步脚本
先创建目录与空快照:
mkdir scripts
mkdir content
mkdir public/notionWindows PowerShell 可以逐个创建:
New-Item -ItemType Directory -Force scripts, content, public/notion创建 content/notion-snapshot.json:
{
"generatedAt": null,
"latestEditedAt": null,
"posts": []
}创建 scripts/sync-notion.mjs。下面是一个可运行的最小版本,包含分页、递归区块、发布过滤、重试和旧快照降级:
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const API = "https://api.notion.com/v1";
const VERSION = "2026-03-11";
const token = process.env.NOTION_TOKEN;
const dataSourceId = process.env.NOTION_ZH_DATA_SOURCE_ID;
const snapshotPath = join(process.cwd(), "content", "notion-snapshot.json");
async function oldSnapshot() {
try {
return JSON.parse(await readFile(snapshotPath, "utf8"));
} catch {
return { generatedAt: null, latestEditedAt: null, posts: [] };
}
}
if (!token || !dataSourceId) {
const snapshot = await oldSnapshot();
console.log(`[notion-sync] no credentials; using ${snapshot.posts.length} cached posts`);
process.exit(0);
}
async function notion(path, init = {}) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${API}${path}`, {
...init,
signal: AbortSignal.timeout(20000),
headers: {
Authorization: `Bearer ${token}`,
"Notion-Version": VERSION,
"Content-Type": "application/json",
...init.headers,
},
});
if (response.ok) return response.json();
if (response.status !== 429 && response.status < 500) {
throw new Error(`Notion ${response.status}: ${path}`);
}
await new Promise((resolve) => setTimeout(resolve, 400 * 2 ** attempt));
}
throw new Error(`Notion retry exhausted: ${path}`);
}
function prop(properties, name) {
const key = Object.keys(properties ?? {}).find(
(item) => item.toLowerCase() === name.toLowerCase(),
);
return key ? properties[key] : undefined;
}
function text(value) {
return [...(value?.title ?? []), ...(value?.rich_text ?? [])]
.map((item) => item.plain_text ?? "")
.join("")
.trim();
}
function selected(value) {
return value?.select?.name ?? value?.status?.name ?? "";
}
function mapPage(page) {
const p = page.properties;
const title = text(prop(p, "title"));
const slug = text(prop(p, "slug"));
const status = selected(prop(p, "status"));
const type = selected(prop(p, "type"));
if (!title || !slug || status !== "Published" || type !== "Post") return null;
return {
id: page.id,
notionId: page.id,
title,
slug: slug.replace(/^\/+|\/+$/g, ""),
description: text(prop(p, "summary")) || "一篇来自 Notion 的文章。",
publishedAt: prop(p, "date")?.date?.start?.slice(0, 10)
?? page.last_edited_time.slice(0, 10),
updatedAt: page.last_edited_time,
category: selected(prop(p, "category")) || "未分类",
tags: (prop(p, "tags")?.multi_select ?? []).map((tag) => tag.name),
featured: prop(p, "featured")?.checkbox ?? false,
};
}
async function queryPosts() {
const pages = [];
let cursor;
do {
const result = await notion(`/data_sources/${dataSourceId}/query`, {
method: "POST",
body: JSON.stringify({
page_size: 100,
...(cursor ? { start_cursor: cursor } : {}),
}),
});
pages.push(...result.results);
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
return pages.map(mapPage).filter(Boolean);
}
async function children(blockId) {
const blocks = [];
let cursor;
do {
const query = new URLSearchParams({ page_size: "100" });
if (cursor) query.set("start_cursor", cursor);
const result = await notion(`/blocks/${blockId}/children?${query}`);
blocks.push(...result.results);
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
for (const block of blocks) {
if (block.has_children) block.children = await children(block.id);
}
return blocks;
}
try {
const posts = await queryPosts();
for (const post of posts) post.blocks = await children(post.notionId);
posts.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const generatedAt = new Date().toISOString();
const latestEditedAt = posts.map((post) => post.updatedAt).sort().at(-1) ?? null;
await mkdir(join(process.cwd(), "content"), { recursive: true });
await writeFile(
snapshotPath,
JSON.stringify({ generatedAt, latestEditedAt, posts }, null, 2) + "\n",
"utf8",
);
console.log(`[notion-sync] generated ${posts.length} posts`);
} catch (error) {
const snapshot = await oldSnapshot();
if (!snapshot.posts.length) throw error;
console.warn(`[notion-sync] failed; preserving ${snapshot.posts.length} cached posts`);
}运行同步:
npm run sync:notion然后打开 content/notion-snapshot.json。你应该能看到 hello-notion 以及正文 blocks。
同步结果仍是 0 篇怎么办
按顺序检查:
- Integration 是否连接到数据库;
- Data Source ID 是否正确;
- 测试页
status是否精确为Published; type是否精确为Post;title、slug是否填写;- Token 是否复制完整;
- 字段类型是否正确,例如 tags 必须是 Multi-select。
7. 在前端读取快照
创建 lib/notion.ts:
import snapshot from "@/content/notion-snapshot.json";
export type Post = {
id: string;
title: string;
slug: string;
description: string;
publishedAt: string;
updatedAt: string;
category: string;
tags: string[];
featured: boolean;
blocks: NotionBlock[];
};
export type NotionBlock = {
id: string;
type: string;
has_children?: boolean;
children?: NotionBlock[];
[key: string]: unknown;
};
const posts = (snapshot.posts ?? []) as Post[];
export async function getAllPosts() {
return [...posts].sort(
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
);
}
export async function getPostBySlug(slug: string) {
return posts.find((post) => post.slug === slug) ?? null;
}这个文件只读取本地 JSON。线上访问文章时不会调用 Notion。
8. 实现一个最小 Notion 渲染器
创建 components/NotionContent.tsx:
import type { ReactNode } from "react";
import type { NotionBlock } from "@/lib/notion";
function richText(items: any[] = []) {
return items.map((item, index) => {
let value: ReactNode = item.plain_text ?? item.text?.content ?? "";
const a = item.annotations ?? {};
if (a.code) value = <code>{value}</code>;
if (a.bold) value = <strong>{value}</strong>;
if (a.italic) value = <em>{value}</em>;
if (a.strikethrough) value = <s>{value}</s>;
const href = item.href ?? item.text?.link?.url;
if (href) value = <a href={href}>{value}</a>;
return <span key={index}>{value}</span>;
});
}
function payload(block: NotionBlock): any {
return block[block.type];
}
function renderBlock(block: NotionBlock): ReactNode {
const data = payload(block) ?? {};
const text = richText(data.rich_text);
const nested = block.children?.map(renderBlock);
switch (block.type) {
case "paragraph":
return <p key={block.id}>{text}{nested}</p>;
case "heading_1":
return <h2 key={block.id}>{text}</h2>;
case "heading_2":
return <h3 key={block.id}>{text}</h3>;
case "heading_3":
return <h4 key={block.id}>{text}</h4>;
case "bulleted_list_item":
return <li key={block.id}>{text}{nested}</li>;
case "numbered_list_item":
return <li key={block.id}>{text}{nested}</li>;
case "quote":
return <blockquote key={block.id}>{text}{nested}</blockquote>;
case "code":
return <pre key={block.id}><code>{data.rich_text?.map((x: any) => x.plain_text).join("")}</code></pre>;
case "divider":
return <hr key={block.id} />;
case "image": {
const src = data.file?.url ?? data.external?.url;
return src ? <img key={block.id} src={src} alt="" loading="lazy" /> : null;
}
default:
return nested?.length ? <div key={block.id}>{nested}</div> : null;
}
}
export function NotionContent({ blocks }: { blocks: NotionBlock[] }) {
return <div className="notion-content">{blocks.map(renderBlock)}</div>;
}这个最小版本足够显示段落、标题、列表项、引用、代码和图片。生产版本还应继续处理:
- 连续列表项分组为同一个
<ul>或<ol>; - Callout、Toggle、Table、Column、Synced Block;
- KaTeX 数学公式;
- highlight.js 代码高亮;
- Notion 文件 URL 本地化,避免临时签名过期;
- 标题锚点和文章目录。
先让最小版本跑通,再逐类扩展,不要一开始就实现所有 Notion Block。
9. 创建首页和文章路由
把 app/page.tsx 替换为:
import Link from "next/link";
import { getAllPosts } from "@/lib/notion";
export const dynamic = "force-static";
export default async function Home() {
const posts = await getAllPosts();
return (
<main className="container">
<header className="hero">
<p>Personal Blog</p>
<h1>写技术,也记录技术之外的生活。</h1>
</header>
<section>
{posts.map((post) => (
<article key={post.id} className="post-card">
<p>{post.category} · {post.publishedAt}</p>
<h2><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
<p>{post.description}</p>
</article>
))}
</section>
</main>
);
}创建 app/posts/[slug]/page.tsx:
import { notFound } from "next/navigation";
import { NotionContent } from "@/components/NotionContent";
import { getAllPosts, getPostBySlug } from "@/lib/notion";
export async function generateStaticParams() {
return (await getAllPosts()).map((post) => ({ slug: post.slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return (
<main className="article">
<a href="/">← 返回首页</a>
<header>
<p>{post.category} · {post.publishedAt}</p>
<h1>{post.title}</h1>
<p>{post.description}</p>
</header>
<NotionContent blocks={post.blocks} />
</main>
);
}在 app/globals.css 添加一个可用的基础样式:
:root {
color: #22211f;
background: #f4f1ea;
font-family: Inter, system-ui, sans-serif;
}
body { margin: 0; }
a { color: inherit; }
.container, .article { width: min(760px, calc(100% - 40px)); margin: 0 auto; }
.hero { padding: 96px 0 64px; }
.hero h1 { font: 500 clamp(42px, 8vw, 80px)/1.04 Georgia, serif; }
.post-card { padding: 32px 0; border-top: 1px solid #cbc5ba; }
.article { padding: 72px 0 120px; }
.article > header { margin: 48px 0 64px; }
.article h1 { font: 500 clamp(40px, 7vw, 72px)/1.08 Georgia, serif; }
.notion-content { font-size: 18px; line-height: 1.85; }
.notion-content h2, .notion-content h3 { margin-top: 2.4em; font-family: Georgia, serif; }
.notion-content pre { overflow: auto; padding: 20px; color: #eee; background: #1e1f22; border-radius: 12px; }
.notion-content img { display: block; max-width: 100%; height: auto; margin: 32px 0; border-radius: 12px; }
.notion-content blockquote { margin-left: 0; padding-left: 20px; border-left: 2px solid #8c806c; }重新启动:
npm run dev打开:
http://localhost:3000
http://localhost:3000/posts/hello-notion10. 本地构建检查
执行:
npm run lint
npm run build构建失败时先看第一条真正的 Error,不要被后面大量连锁报错干扰。
常见问题:
Cannot find module notion-snapshot.json:没有创建初始快照;generated 0 posts:Notion 字段、状态、权限或 Data Source ID 错误;401 unauthorized:Token 错误或 Integration 没有数据库权限;404 object_not_found:传入了 View ID、Database URL,或数据库未共享;- TypeScript 报 JSON 类型错误:给
snapshot.posts做显式类型断言。
11. 推送到 GitHub
在 GitHub 新建一个仓库,例如 notion-blog,不要上传 .env.local。
本地执行:
git init
git add .
git commit -m "feat: build Notion powered blog"
git branch -M main
git remote add origin git@github.com:你的用户名/notion-blog.git
git push -u origin main到 GitHub 仓库页面检查文件列表,确认不存在 .env.local 和真实 Token。
12. 部署到 Netlify
12.1 导入仓库
- 登录 Netlify;
- 选择 Add new project;
- 选择 Import an existing project;
- 连接 GitHub;
- 选择
notion-blog仓库。
构建设置填写:
Build command: npm run build:netlify
Publish directory: dist
Node version: 22在根目录创建 netlify.toml:
[build]
command = "npm run build:netlify"
publish = "dist"
[build.environment]
NODE_VERSION = "22.14.0"
NITRO_PRESET = "netlify"
[functions]
directory = "netlify/functions"12.2 配置 Netlify 环境变量
进入 Project configuration → Environment variables,添加:
NOTION_TOKEN=你的真实Token
NOTION_ZH_DATA_SOURCE_ID=你的DataSourceID
NEXT_PUBLIC_SITE_URL=https://Netlify分配的域名然后点击 Deploy。
在 Deploy log 中搜索:
[notion-sync] generated 1 posts如果日志显示 no credentials,说明变量没有配置到 Production 构建上下文。修改变量后必须重新 Deploy,已经生成的部署不会自动获得新变量。
13. 配置 Notion 修改后自动发布
先完成手动部署,再做自动化。这样出问题时容易判断是“内容同步”还是“Webhook”导致的。
13.1 创建 Netlify Build Hook
进入 Netlify:
Project configuration → Build & deploy → Build hooks创建名为 Notion publish 的 Build Hook,分支选择 main。复制生成的 URL,并添加为环境变量:
NETLIFY_BUILD_HOOK=https://api.netlify.com/build_hooks/xxxxxxxx13.2 创建 Webhook Function
创建 netlify/functions/notion-webhook.mjs:
import { createHmac, timingSafeEqual } from "node:crypto";
const accepted = new Set([
"page.created",
"page.content_updated",
"page.properties_updated",
"page.deleted",
"page.undeleted",
]);
function validSignature(raw, signature) {
const secret = process.env.NOTION_WEBHOOK_SECRET;
if (!secret || !signature) return false;
const expected = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;
return signature.length === expected.length
&& timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export default async function handler(request) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const raw = await request.text();
const payload = JSON.parse(raw);
// Notion 创建订阅时会先发送验证令牌。
if (payload.verification_token) {
console.log(`[notion-webhook] verification_token=${payload.verification_token}`);
return Response.json({ ok: true, verification_token: payload.verification_token });
}
if (!validSignature(raw, request.headers.get("x-notion-signature"))) {
return new Response("Invalid signature", { status: 401 });
}
if (accepted.has(payload.type)) {
const response = await fetch(process.env.NETLIFY_BUILD_HOOK, { method: "POST" });
if (!response.ok) return new Response("Build hook failed", { status: 502 });
}
return Response.json({ ok: true });
}
export const config = { path: "/api/notion-webhook" };提交并部署后,Webhook URL 是:
https://你的域名/api/notion-webhook13.3 在 Notion 创建 Webhook
在 Notion Integration 的 Webhook 设置中:
- 添加上面的 URL;
- 订阅页面创建、正文更新、属性更新、删除和恢复事件;
- 点击发送验证请求;
- 去 Netlify Function log 找到
verification_token; - 把令牌填回 Notion 完成验证;
- 将最终得到的签名 Secret 保存为 Netlify 的
NOTION_WEBHOOK_SECRET。
最后重新部署一次,让 Secret 生效。
13.4 验证自动发布
在 Notion 测试文章末尾增加一句话。然后:
- 查看 Netlify Deploys 是否出现新构建;
- 等构建完成;
- 刷新文章页;
- 确认新文字出现。
如果没有自动构建:
- 查看 Netlify Function log 是否收到请求;
- 检查 Notion Webhook 是否 Verified;
- 检查事件类型是否订阅;
- 检查
NETLIFY_BUILD_HOOK是否能手动 POST; - 修改 Secret 后是否重新部署。
14. 增加每 30 分钟一次的补偿检查
Webhook 可能因网络或配置变化丢失事件。生产站应增加低频补偿,而不是完全相信一次通知。
创建 public/notion-snapshot-meta.json:
{
"generatedAt": null,
"latestEditedAt": null,
"postCount": 0
}同步成功时同时把 generatedAt、latestEditedAt、postCount 写进这个公开文件。然后创建 Netlify Scheduled Function,每 30 分钟比较:
Notion 最近 Published Post 的 last_edited_time
线上 notion-snapshot-meta.json 的 latestEditedAt两者不同才触发 Build Hook。对应 schedule 为:
export const config = {
schedule: "*/30 * * * *",
};这个机制的目的不是每 30 分钟都构建,而是保证 Webhook 漏掉时最终仍能恢复一致。
15. 配置自定义域名
在 Netlify 的 Domain management 中添加你的域名。Netlify 会显示应填写的 DNS 记录。
如果域名在阿里云:
- 打开云解析 DNS;
www通常按 Netlify 页面提供的目标添加 CNAME;- 根域名
@按 Netlify 当前页面给出的 A/ALIAS 记录填写; - 不要凭旧教程猜 IP,始终使用 Netlify 针对你项目显示的值;
- 等待 DNS 生效和 HTTPS 证书签发。
先同时保留 Netlify 默认域名,直到自定义域名显示 HTTPS 正常,再把它设为 Primary domain。
更新环境变量:
NEXT_PUBLIC_SITE_URL=https://你的正式域名重新部署后检查 canonical URL 和站内链接。
16. 上线 Giscus 评论
- 在 GitHub 新建一个公开仓库,例如
blog-comments; - Repository Settings → General → Features,开启 Discussions;
- 安装 Giscus GitHub App,只授权评论仓库;
- 打开 giscus.app/zh-CN;
- 输入
用户名/blog-comments; - Mapping 选择
pathname; - Category 选择
Announcements; - 打开 strict、reactions 和 lazy loading;
- 复制四个配置值到 Netlify。
NEXT_PUBLIC_GISCUS_REPO=用户名/blog-comments
NEXT_PUBLIC_GISCUS_REPO_ID=R_xxxxxxxxx
NEXT_PUBLIC_GISCUS_CATEGORY=Announcements
NEXT_PUBLIC_GISCUS_CATEGORY_ID=DIC_xxxxxxxxx文章页通过 Giscus 脚本加载评论。环境变量不完整时应显示友好占位,而不是让页面报错。评论 iframe 建议懒加载,避免拖慢首屏。
17. 图片本地化:正式使用前必须补上
Notion 上传文件返回的 URL 带有过期签名。测试时图片正常,不代表几天后仍然正常。
可靠做法是在同步阶段:
- 找到 image block 的
file.url或external.url; - 下载图片并检查 Content-Type;
- 限制单图大小,例如 12 MB;
- 用 block ID + SHA-256 生成稳定文件名;
- 保存到
public/notion/; - 把快照中的远程 URL 替换为
/notion/文件名; - 同步结束后清理已经不再引用的旧图片。
这样图片会跟随 Netlify 部署进入 CDN,不再依赖 Notion 临时链接。
18. 日常发布文章的固定流程
以后每篇文章只需要:
title;slug;summary;date、category、tags;type 设为 Post;status 设为 Published;/posts/你的-slug 检查正文、代码、公式和图片。修改已经发布的文章时同样会触发构建。想临时隐藏文章,可把 status 改成 Invisible 或 Draft,下一次构建后它会从网站快照中消失。
19. 最终验收清单
| 检查项 | 通过标准 |
|---|---|
| 首页 | 能列出所有 Published + Post 文章 |
| 文章路由 | /posts/slug 返回 200 |
| 草稿保护 | Draft 页面不出现在网页 |
| 构建稳定性 | Notion 暂时失败时保留旧快照 |
| 图片 | 使用站内 /notion/ 路径而非临时 URL |
| 自动发布 | 修改 Notion 后出现新 Netlify Deploy |
| HTTPS | 自定义域名无证书警告 |
| 评论 | GitHub 登录后能创建 Discussion 评论 |
20. 下一步如何从“能用”做到“好用”
完成上述步骤后,你已经拥有完整发布闭环。接下来按优先级增强:
- 完善 Notion Block 渲染:列表分组、Callout、Toggle、Table、KaTeX、代码高亮;
- 增加 Archive、Category、Tag、Series 页面;
- 生成 RSS、sitemap 和 Open Graph 图片;
- 优化字体、文章宽度、目录、阅读进度与移动端布局;
- 给页面切换添加短而克制的过渡,不阻塞导航;
- 增加评论、浏览量和隐私友好的统计;
- 为同步脚本添加渲染测试,防止换行、列表或公式回归。