这是一份从空目录开始的实操教程。每一步都包含操作、代码、验证方法和常见错误。建议按顺序完成,不要跳过每节末尾的“完成标志”。

0. 开始前:准备账号和工具

你需要:

  • 一个 Notion 账号;
  • 一个 GitHub 账号;
  • 一个 Netlify 账号;
  • Node.js 22 或更高版本;
  • Git;
  • 一个代码编辑器,例如 VS Code。

在终端中检查环境:

Shell
node --version
git --version
npm --version

Node 版本应为 v22.x 或更高。如果命令不存在,先安装 Node.js LTSGit

预计时间: 第一次操作约 1~2 小时。Notion、GitHub、Netlify 免费套餐足以运行个人博客;域名是唯一必需的可选付费项。

1. 理解最终的发布链路

先明确我们要搭建的不是“访问时从 Notion 读取”的动态站点,而是“构建时同步”的静态内容站:

Plain text
Notion 写作
   ↓
同步脚本读取数据库和正文区块
   ↓
生成 content/notion-snapshot.json
   ↓
Next.js 根据快照生成首页和文章页
   ↓
Netlify 构建并发布到 CDN

这样做有三个直接收益:

  1. 读者访问文章时不请求 Notion,打开速度更稳定;
  2. Notion 临时不可用时,线上旧文章仍能正常访问;
  3. 首页、归档、RSS、站点地图都可以复用同一份内容快照。

2. 创建本地项目

打开终端,执行:

Shell
npx create-next-app@latest notion-blog --typescript --eslint --app --no-src-dir --import-alias "@/*"
cd notion-blog
npm install

安装正文渲染所需依赖:

Shell
npm install katex highlight.js
npm install -D vinext vite nitro @vitejs/plugin-react @vitejs/plugin-rsc

package.json 的 scripts 修改为:

JSON
{
  "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

TypeScript
import vinext from "vinext";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [vinext(), nitro({ preset: "netlify" })],
});

启动空项目:

Shell
npm run dev

浏览器打开终端显示的本地地址,通常是 http://localhost:3000

3. 创建 Notion 博客数据库

3.1 新建数据库

在 Notion 中新建一个全页 Table Database,命名为“我的博客”。保留唯一的 Title 属性,并把它改名为 title

按下表添加属性。字段名和选项值建议完全照抄,大小写也保持一致:

字段名Notion 类型选项或示例
titleTitle我的第一篇文章
slugTextmy-first-post
statusSelectDraft、Published、Invisible
typeSelectPost
summaryText文章摘要
dateDate发布日期
categorySelect技术分享、随笔等
tagsMulti-select开发、建站、Notion 等
featuredCheckbox首页置顶
SeriesSelect可选的系列名称
Series OrderNumber系列内部顺序

3.2 创建测试文章

新建一行并填写:

Plain text
title: Hello Notion
slug: hello-notion
status: Published
type: Post
summary: 我的第一篇 Notion 博客文章
date: 今天
category: 技术分享
tags: Notion, 建站

点开页面,在正文中添加:

  • 一个二级标题;
  • 两段文字;
  • 一个代码块;
  • 一张图片。

4. 创建 Notion Integration

  1. 打开 Notion Connections
  2. 选择创建新的 Integration。
  3. 名称可以填 My Blog
  4. 选择博客数据库所在的 Workspace。
  5. 只开启读取内容所需权限。
  6. 创建后复制 Internal Integration Token。

然后回到“我的博客”数据库:

  1. 点击右上角 •••
  2. 找到 Connections 或“连接”;
  3. 添加刚创建的 My Blog Integration。

仅仅创建 Integration 不够,数据库还必须显式共享给它。

4.1 获取 Data Source ID

打开数据库,复制浏览器地址。新版 Notion API 使用 Data Source ID,而不是旧教程中常见的 Database ID。

最稳妥的方法是调用 Notion API 获取数据库信息,或在 Notion 开发者页面查看 Data Sources。最终得到一段 UUID,例如:

Plain text
1e34cfb3-d63d-8143-9a2a-000b424944ba

不要使用包含 ?v= 的视图 ID,也不要把整个 Notion URL 当成 Data Source ID。

5. 配置本地环境变量

在项目根目录创建 .env.local

JavaScript
NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxx
NOTION_ZH_DATA_SOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
NEXT_PUBLIC_SITE_URL=http://localhost:3000

创建 .env.example,只保留变量名,不放真实秘密:

JavaScript
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 包含:

Plain text
.env
.env.local
.env.*.local

6. 创建内容快照和同步脚本

先创建目录与空快照:

Shell
mkdir scripts
mkdir content
mkdir public/notion

Windows PowerShell 可以逐个创建:

powershell
New-Item -ItemType Directory -Force scripts, content, public/notion

创建 content/notion-snapshot.json

JSON
{
  "generatedAt": null,
  "latestEditedAt": null,
  "posts": []
}

创建 scripts/sync-notion.mjs。下面是一个可运行的最小版本,包含分页、递归区块、发布过滤、重试和旧快照降级:

JavaScript
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`);
}

运行同步:

Shell
npm run sync:notion

然后打开 content/notion-snapshot.json。你应该能看到 hello-notion 以及正文 blocks。

同步结果仍是 0 篇怎么办

按顺序检查:

  1. Integration 是否连接到数据库;
  2. Data Source ID 是否正确;
  3. 测试页 status 是否精确为 Published
  4. type 是否精确为 Post
  5. titleslug 是否填写;
  6. Token 是否复制完整;
  7. 字段类型是否正确,例如 tags 必须是 Multi-select。

7. 在前端读取快照

创建 lib/notion.ts

TypeScript
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

TypeScript
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 替换为:

TypeScript
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

TypeScript
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 添加一个可用的基础样式:

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; }

重新启动:

Shell
npm run dev

打开:

Plain text
http://localhost:3000
http://localhost:3000/posts/hello-notion

10. 本地构建检查

执行:

Shell
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

本地执行:

Shell
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 导入仓库

  1. 登录 Netlify;
  2. 选择 Add new project;
  3. 选择 Import an existing project;
  4. 连接 GitHub;
  5. 选择 notion-blog 仓库。

构建设置填写:

Plain text
Build command: npm run build:netlify
Publish directory: dist
Node version: 22

在根目录创建 netlify.toml

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,添加:

JavaScript
NOTION_TOKEN=你的真实Token
NOTION_ZH_DATA_SOURCE_ID=你的DataSourceID
NEXT_PUBLIC_SITE_URL=https://Netlify分配的域名

然后点击 Deploy。

在 Deploy log 中搜索:

Plain text
[notion-sync] generated 1 posts

如果日志显示 no credentials,说明变量没有配置到 Production 构建上下文。修改变量后必须重新 Deploy,已经生成的部署不会自动获得新变量。

13. 配置 Notion 修改后自动发布

先完成手动部署,再做自动化。这样出问题时容易判断是“内容同步”还是“Webhook”导致的。

13.1 创建 Netlify Build Hook

进入 Netlify:

Plain text
Project configuration → Build & deploy → Build hooks

创建名为 Notion publish 的 Build Hook,分支选择 main。复制生成的 URL,并添加为环境变量:

JavaScript
NETLIFY_BUILD_HOOK=https://api.netlify.com/build_hooks/xxxxxxxx

13.2 创建 Webhook Function

创建 netlify/functions/notion-webhook.mjs

JavaScript
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 是:

Plain text
https://你的域名/api/notion-webhook

13.3 在 Notion 创建 Webhook

在 Notion Integration 的 Webhook 设置中:

  1. 添加上面的 URL;
  2. 订阅页面创建、正文更新、属性更新、删除和恢复事件;
  3. 点击发送验证请求;
  4. 去 Netlify Function log 找到 verification_token
  5. 把令牌填回 Notion 完成验证;
  6. 将最终得到的签名 Secret 保存为 Netlify 的 NOTION_WEBHOOK_SECRET

最后重新部署一次,让 Secret 生效。

13.4 验证自动发布

在 Notion 测试文章末尾增加一句话。然后:

  1. 查看 Netlify Deploys 是否出现新构建;
  2. 等构建完成;
  3. 刷新文章页;
  4. 确认新文字出现。

如果没有自动构建:

  • 查看 Netlify Function log 是否收到请求;
  • 检查 Notion Webhook 是否 Verified;
  • 检查事件类型是否订阅;
  • 检查 NETLIFY_BUILD_HOOK 是否能手动 POST;
  • 修改 Secret 后是否重新部署。

14. 增加每 30 分钟一次的补偿检查

Webhook 可能因网络或配置变化丢失事件。生产站应增加低频补偿,而不是完全相信一次通知。

创建 public/notion-snapshot-meta.json

JSON
{
  "generatedAt": null,
  "latestEditedAt": null,
  "postCount": 0
}

同步成功时同时把 generatedAtlatestEditedAtpostCount 写进这个公开文件。然后创建 Netlify Scheduled Function,每 30 分钟比较:

Plain text
Notion 最近 Published Post 的 last_edited_time
线上 notion-snapshot-meta.json 的 latestEditedAt

两者不同才触发 Build Hook。对应 schedule 为:

JavaScript
export const config = {
  schedule: "*/30 * * * *",
};

这个机制的目的不是每 30 分钟都构建,而是保证 Webhook 漏掉时最终仍能恢复一致。

15. 配置自定义域名

在 Netlify 的 Domain management 中添加你的域名。Netlify 会显示应填写的 DNS 记录。

如果域名在阿里云:

  1. 打开云解析 DNS;
  2. www 通常按 Netlify 页面提供的目标添加 CNAME;
  3. 根域名 @ 按 Netlify 当前页面给出的 A/ALIAS 记录填写;
  4. 不要凭旧教程猜 IP,始终使用 Netlify 针对你项目显示的值;
  5. 等待 DNS 生效和 HTTPS 证书签发。

先同时保留 Netlify 默认域名,直到自定义域名显示 HTTPS 正常,再把它设为 Primary domain。

更新环境变量:

JavaScript
NEXT_PUBLIC_SITE_URL=https://你的正式域名

重新部署后检查 canonical URL 和站内链接。

16. 上线 Giscus 评论

  1. 在 GitHub 新建一个公开仓库,例如 blog-comments
  2. Repository Settings → General → Features,开启 Discussions;
  3. 安装 Giscus GitHub App,只授权评论仓库;
  4. 打开 giscus.app/zh-CN
  5. 输入 用户名/blog-comments
  6. Mapping 选择 pathname
  7. Category 选择 Announcements
  8. 打开 strict、reactions 和 lazy loading;
  9. 复制四个配置值到 Netlify。
JavaScript
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 带有过期签名。测试时图片正常,不代表几天后仍然正常。

可靠做法是在同步阶段:

  1. 找到 image block 的 file.urlexternal.url
  2. 下载图片并检查 Content-Type;
  3. 限制单图大小,例如 12 MB;
  4. 用 block ID + SHA-256 生成稳定文件名;
  5. 保存到 public/notion/
  6. 把快照中的远程 URL 替换为 /notion/文件名
  7. 同步结束后清理已经不再引用的旧图片。

这样图片会跟随 Netlify 部署进入 CDN,不再依赖 Notion 临时链接。

18. 日常发布文章的固定流程

以后每篇文章只需要:

在 Notion 博客数据库中新建页面;
填写 title
填写唯一的英文 slug
填写 summary
设置 datecategorytags
type 设为 Post
写完正文后将 status 设为 Published
等待 Netlify 构建完成;
打开 /posts/你的-slug 检查正文、代码、公式和图片。

修改已经发布的文章时同样会触发构建。想临时隐藏文章,可把 status 改成 InvisibleDraft,下一次构建后它会从网站快照中消失。

19. 最终验收清单

检查项通过标准
首页能列出所有 Published + Post 文章
文章路由/posts/slug 返回 200
草稿保护Draft 页面不出现在网页
构建稳定性Notion 暂时失败时保留旧快照
图片使用站内 /notion/ 路径而非临时 URL
自动发布修改 Notion 后出现新 Netlify Deploy
HTTPS自定义域名无证书警告
评论GitHub 登录后能创建 Discussion 评论

20. 下一步如何从“能用”做到“好用”

完成上述步骤后,你已经拥有完整发布闭环。接下来按优先级增强:

  1. 完善 Notion Block 渲染:列表分组、Callout、Toggle、Table、KaTeX、代码高亮;
  2. 增加 Archive、Category、Tag、Series 页面;
  3. 生成 RSS、sitemap 和 Open Graph 图片;
  4. 优化字体、文章宽度、目录、阅读进度与移动端布局;
  5. 给页面切换添加短而克制的过渡,不阻塞导航;
  6. 增加评论、浏览量和隐私友好的统计;
  7. 为同步脚本添加渲染测试,防止换行、列表或公式回归。