FIELD NOTE · 2026-08-06
用 Next.js 从零搭建个人博客
你现在看到的这个网站,就是按这篇文章的流程从零搭起来的。文章不存数据库,全部是本地 Markdown 文件;页面在构建时生成,打开飞快;评论和留言由 Waline 提供;代码推到 GitHub 后,Vercel 自动部署上线。整个过程不依赖任何平台锁定的服务,数据和源码随时可以搬走。
本文写给想自己动手、又希望长期维护简单的人。你只需要会一点命令行(打开终端、敲几条命令),其余我会一步步讲清楚。
1. 整体思路与技术选型
先想清楚目标,再选工具。我的需求是:
- 页面要快,最好构建时直接生成静态页面;
- 写作要简单,用 Markdown 写文章,不碰数据库;
- 要有评论区,但评论数据要能自己掌控;
- 部署免费,push 代码就自动上线。
对应的选型:
| 部分 | 选择 | 理由 |
|---|---|---|
| 框架 | Next.js 15(App Router)+ TypeScript | 页面即文件、路由清晰,支持静态生成,类型检查减少低级错误 |
| 内容 | 本地 Markdown(content/posts/) | 文章就是文件,好备份、好迁移、写起来零负担 |
| 渲染 | react-markdown + remark-gfm + rehype-raw | 安全渲染 Markdown,支持表格/任务列表,需要时可直接写 HTML 控制图片 |
| 评论 | Waline | 免费、开源、自托管,数据存在自己的数据库里 |
| 部署 | GitHub + Vercel | 推代码即部署,免费额度对个人博客绰绰有余 |
一句话概括思路:文章是文件,网站是程序,构建时把文件变成页面。
2. 准备工作
- 安装 Node.js 20 或更高版本(终端里运行
node -v能看到版本号); - 一个 GitHub 账号;
- 一个顺手的代码编辑器。写代码我用 VS Code,日常写文章用 Obsidian(后面第 14 节会讲)。
3. 初始化项目
打开终端,在想要放项目的目录里运行:
npx create-next-app@latest dcblog
过程中按提示选择(不同版本提示略有差异):
- TypeScript:Yes
- ESLint:Yes(帮你检查代码问题)
- Tailwind CSS:No(本项目用纯 CSS,样式自己写)
- 使用 src/ 目录:No(代码直接放在根目录)
- App Router:Yes
- Turbopack:Yes(本地开发更快)
- import alias:
@/*(默认即可)
接着安装渲染 Markdown 和评论功能需要的依赖:
cd dcblog
npm install react-markdown remark-gfm rehype-raw @waline/client
启动本地预览:
npm run dev
浏览器打开 http://localhost:3000 ,能看到 Next.js 的初始页面,说明环境没问题。
4. 规划目录
动手写代码之前,先把目录结构定下来。我的最终结构是这样:
dcblog/
├─ app/ # 所有页面与路由
│ ├─ layout.tsx # 整站外壳:导航栏、页脚、SEO
│ ├─ page.tsx # 首页
│ ├─ about/ # 关于我
│ ├─ projects/ # 项目列表
│ ├─ tags/ # 标签总览 + 标签详情
│ ├─ archive/ # 归档
│ ├─ search/ # 搜索
│ ├─ message-board/ # 留言板
│ ├─ posts/[slug]/ # 文章详情
│ ├─ rss.xml/ # RSS 订阅
│ ├─ sitemap.ts # 站点地图
│ └─ globals.css # 全站样式
├─ components/ # 可复用组件:导航栏、文章卡片等
├─ content/
│ ├─ posts/ # 文章(Markdown)
│ ├─ projects/ # 项目(Markdown)
│ └─ site.ts # 站点配置:站名、作者、邮箱、链接
├─ lib/ # 工具函数
│ ├─ posts.ts # 读取、整理文章与项目
│ ├─ types.ts # 类型定义
│ └─ image-path.ts # 图片路径修正
└─ public/images/ # 文章插图
5. 内容层:文章就是 Markdown 文件
5.1 文章长什么样
每篇文章是 content/posts/ 下的一个 .md 文件,开头用 --- 包住一段元信息(frontmatter):
---
title: 文章标题
date: 2026-08-06
tags: [随笔, 博客建站]
excerpt: 一句话摘要
draft: false
---
这里是正文,直接写 Markdown。
字段含义:
title:文章标题;date:发布日期,格式YYYY-MM-DD,用于排序和归档;tags:标签,列表或[a, b]写法都可以;excerpt:列表页和搜索引擎显示的摘要,不写会自动从正文截取;draft: true:草稿,构建时会被跳过,不会发布。
项目页的数据同理,放在 content/projects/,frontmatter 是 name / summary / year / tags / links / featured。其中 links 用 标签|网址, 标签|网址 的写法,例如:
links: 预览|https://你的域名.com, 文档|https://example.com/docs
5.2 类型定义
先定义好数据的形状,后面读写都有据可依。lib/types.ts:
export interface Post {
slug: string;
title: string;
date: string;
tags: string[];
excerpt: string;
content: string;
readingTime: string;
}
export interface Project {
slug: string;
name: string;
summary: string;
year: string;
tags: string[];
links: { label: string; href: string }[];
featured: boolean;
}
export interface Tag {
name: string;
count: number;
}
5.3 读取与整理:lib/posts.ts
这是内容层的核心:读文件、解析 frontmatter、过滤草稿、按日期倒序、统计标签、计算阅读时间。下面的代码是这个项目实际在用的完整实现(注释做了精简):
import fs from "node:fs";
import path from "node:path";
import type { Post, Project, Tag } from "@/lib/types";
const POSTS_DIR = path.join(process.cwd(), "content", "posts");
const PROJECTS_DIR = path.join(process.cwd(), "content", "projects");
// 极简 frontmatter 解析器:支持字符串、布尔值、tags 列表
export function parseFrontmatter(raw: string) {
const lines = raw.split(/\r?\n/);
if (lines[0]?.trim() !== "---") return { data: {}, content: raw };
const endIndex = lines.findIndex(
(line, index) => index > 0 && line.trim() === "---"
);
if (endIndex === -1) return { data: {}, content: raw };
const data: Record<string, unknown> = {};
let listKey: string | null = null;
for (const line of lines.slice(1, endIndex)) {
if (/^\s*-\s+/.test(line) && listKey) {
const current = data[listKey];
if (Array.isArray(current)) {
current.push(
line.trim().replace(/^-\s+/, "").replace(/^["']|["']$/g, "")
);
}
continue;
}
const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
if (!match) continue;
const key = match[1];
let value = match[2].trim();
listKey = key === "tags" ? "tags" : null;
if (key === "tags") {
value = value.replace(/^\[|\]$/g, "");
data[key] = value
.split(/[,,\s]+/)
.map((item) => item.replace(/^["']|["']$/g, ""))
.filter(Boolean);
} else if (value === "true" || value === "false") {
data[key] = value === "true";
} else {
data[key] = value.replace(/^["']|["']$/g, "");
}
}
return { data, content: lines.slice(endIndex + 1).join("\n").trim() };
}
// Markdown → 纯文本,用于摘要和搜索索引
export function markdownToText(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/^>\s?/gm, "")
.replace(/[*_~]+/g, "")
.replace(/[#|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
// 阅读时间估算:中文字符按 350 字/分钟
export function estimateReadingTime(text: string): string {
const cjkChars = (text.match(/[\u4e00-\u9fff\u3040-\u30ff]/g) || []).length;
const latinWords = (text.match(/[a-zA-Z0-9]+/g) || []).length;
const minutes = Math.max(1, Math.round((cjkChars + latinWords) / 350));
return `${minutes} 分钟`;
}
export function formatDate(date: string): string {
const parsed = new Date(`${date}T00:00:00+08:00`);
if (Number.isNaN(parsed.getTime())) return date;
return `${parsed.getFullYear()} 年 ${parsed.getMonth() + 1} 月 ${parsed.getDate()} 日`;
}
function readPost(fileName: string): Post | null {
const raw = fs.readFileSync(path.join(POSTS_DIR, fileName), "utf8");
const { data, content } = parseFrontmatter(raw);
if (data.draft === true) return null;
const title =
typeof data.title === "string" ? data.title : fileName.replace(/\.md$/, "");
const date = typeof data.date === "string" ? data.date : "1970-01-01";
const tags = Array.isArray(data.tags) ? (data.tags as string[]) : [];
const excerpt =
typeof data.excerpt === "string" && data.excerpt.length > 0
? data.excerpt
: `${markdownToText(content).slice(0, 120)}…`;
return {
slug: fileName.replace(/\.md$/, ""),
title,
date,
tags,
excerpt,
content,
readingTime: estimateReadingTime(content),
};
}
export function getAllPosts(): Post[] {
if (!fs.existsSync(POSTS_DIR)) return [];
return fs
.readdirSync(POSTS_DIR)
.filter((fileName) => fileName.endsWith(".md"))
.map(readPost)
.filter((post): post is Post => post !== null)
.sort((a, b) => b.date.localeCompare(a.date));
}
export function getPostBySlug(slug: string): Post | undefined {
return getAllPosts().find((post) => post.slug === slug);
}
export function getAllTags(): Tag[] {
const counts = new Map<string, number>();
for (const post of getAllPosts()) {
for (const tag of post.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return [...counts.entries()].map(([name, count]) => ({ name, count }));
}
export function getPostsByTag(tag: string): Post[] {
return getAllPosts().filter((post) => post.tags.includes(tag));
}
// 相关文章:按共同标签数打分,取前 count 篇
export function getRelatedPosts(post: Post, count = 3): Post[] {
return getAllPosts()
.filter((candidate) => candidate.slug !== post.slug)
.map((candidate) => ({
post: candidate,
score: candidate.tags.filter((tag) => post.tags.includes(tag)).length,
}))
.sort(
(a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date)
)
.slice(0, count)
.map((entry) => entry.post);
}
export function getProjects(): Project[] {
if (!fs.existsSync(PROJECTS_DIR)) return [];
return fs
.readdirSync(PROJECTS_DIR)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => {
const raw = fs.readFileSync(path.join(PROJECTS_DIR, fileName), "utf8");
const { data } = parseFrontmatter(raw);
const links = (typeof data.links === "string" ? data.links : "")
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [label, href] = part.split("|").map((item) => item.trim());
return { label: label || "链接", href: href || "#" };
});
return {
slug: fileName.replace(/\.md$/, ""),
name: typeof data.name === "string" ? data.name : fileName,
summary: typeof data.summary === "string" ? data.summary : "",
year: typeof data.year === "string" ? data.year : "",
tags: Array.isArray(data.tags) ? (data.tags as string[]) : [],
links,
featured: data.featured === true,
};
})
.sort((a, b) => b.year.localeCompare(a.year));
}
有几个地方值得解释:
slug就是文件名去掉.md,所以文章文件名最好用英文短横线(如build-blog-with-nextjs.md);draft: true的文章在readPost里直接返回null,getAllPosts会过滤掉;- 所有函数每次调用都会重新读一遍文件。文章量小(几十篇)时完全够用,没必要引入缓存。
6. 图片路径:解决本地编辑器的相对路径问题
在本地 Markdown 编辑器里粘贴图片,通常会生成类似 ../../public/images/xxx.png 的相对路径。本地看没问题,部署到线上后路径就错了。解决办法是写两个小插件,在渲染前把这类路径统一改写为 /images/xxx.png。lib/image-path.ts:
const ABSOLUTE_URL = /^(?:[a-z]+:|\/|#|data:)/i;
export function normalizeImageUrl(url: string): string {
if (!url || ABSOLUTE_URL.test(url)) return url;
const match = url.match(/(?:^|\/)public\/images\/(.+)$/);
if (match) return `/images/${match[1]}`;
return url;
}
function walk(node: unknown, visit: (node: any) => void): void {
if (!node || typeof node !== "object") return;
visit(node);
const record = node as Record<string, unknown>;
if (Array.isArray(record.children)) {
for (const child of record.children) walk(child, visit);
}
if (record.properties && typeof record.properties === "object") {
for (const value of Object.values(record.properties)) {
if (value && typeof value === "object") walk(value, visit);
}
}
}
// remark 插件:处理 Markdown 语法的图片 
export function remarkNormalizeImages() {
return (tree: any) => {
walk(tree, (node) => {
if (node.type === "image" && typeof node.url === "string") {
node.url = normalizeImageUrl(node.url);
}
});
};
}
// rehype 插件:处理 HTML 写法的 <img src="...">
export function rehypeNormalizeImages() {
return (tree: any) => {
walk(tree, (node) => {
if (
node.type === "element" &&
node.tagName === "img" &&
node.properties?.src
) {
node.properties.src = normalizeImageUrl(node.properties.src);
}
});
};
}
然后把它们和 react-markdown 组装成一个统一的渲染组件 components/Prose.tsx,所有文章内容都走它:
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import {
remarkNormalizeImages,
rehypeNormalizeImages,
} from "@/lib/image-path";
export function Prose({ content }: { content: string }) {
return (
<div className="prose">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNormalizeImages]}
rehypePlugins={[rehypeRaw, rehypeNormalizeImages]}
>
{content}
</ReactMarkdown>
</div>
);
}
启用 rehype-raw 的额外好处是:需要精细排版时,可以直接在 Markdown 里写 HTML(比如控制图片尺寸、居中),比如 <img src="/images/xxx.png" width="600" />。
7. 页面骨架
7.1 站点配置 content/site.ts
站名、作者、邮箱、域名这类全站信息集中放一个文件里,改一处全站生效:
export const SITE = {
title: "我的博客",
author: "我的名字",
tagline: "一句话副标题",
description: "站点描述,会显示在搜索结果里。",
url: "https://你的域名.com",
locale: "zh-CN",
email: "me@example.com",
links: {
github: "https://github.com/你的账号",
rss: "/rss.xml",
},
};
7.2 整站外壳 app/layout.tsx
app/layout.tsx 是所有页面共用的外壳,负责 SEO 信息和整体布局:
import type { Metadata } from "next";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { SITE } from "@/content/site";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL(SITE.url),
title: {
default: `${SITE.title} · ${SITE.tagline}`,
template: `%s · ${SITE.title}`,
},
description: SITE.description,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang={SITE.locale}>
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
Header 和 Footer 是两个简单组件,内部放一个导航数组循环渲染即可(首页、关于、项目、标签、归档、留言板、搜索)。
7.3 首页 app/page.tsx
首页由三部分组成:顶部一句话介绍(hero)、最近三篇文章、代表项目。全部数据来自第 5 节的函数,构建时静态生成:
import Link from "next/link";
import { PostCard } from "@/components/PostCard";
import { ProjectCard } from "@/components/ProjectCard";
import { getAllPosts, getProjects } from "@/lib/posts";
export default function HomePage() {
const posts = getAllPosts().slice(0, 3);
const projects = getProjects().filter((project) => project.featured).slice(0, 2);
return (
<>
<section className="hero">
<h1>欢迎来到我的博客</h1>
<p>这里记录生活与折腾,欢迎路过的人进来坐坐。</p>
</section>
<section>
<h2>最近在写</h2>
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</section>
<section>
<h2>代表项目</h2>
{projects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</section>
</>
);
}
7.4 文章卡片 components/PostCard.tsx
列表页复用的卡片组件,展示日期、标题、摘要、标签:
import Link from "next/link";
import type { Post } from "@/lib/types";
export function PostCard({ post }: { post: Post }) {
return (
<article className="post-card">
<time className="post-card-date" dateTime={post.date}>
{post.date}
</time>
<div className="post-card-body">
<h3 className="post-card-title">
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h3>
<p className="post-card-excerpt">{post.excerpt}</p>
<div className="post-card-meta">
<span>{post.readingTime}</span>
{post.tags.map((tag) => (
<Link key={tag} href={`/tags/${encodeURIComponent(tag)}`}>
{tag}
</Link>
))}
</div>
</div>
</article>
);
}
8. 文章详情页:核心路由
app/posts/[slug]/page.tsx 是整个博客最重要的页面。方括号 [slug] 是 Next.js 的动态路由写法,一个文件就能处理所有文章。
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Prose } from "@/components/Prose";
import { WalineComments } from "@/components/WalineComments";
import {
formatDate,
getAllPosts,
getPostBySlug,
getRelatedPosts,
} from "@/lib/posts";
interface Props {
params: Promise<{ slug: string }>;
}
// 构建时枚举所有文章,生成静态页面
export async function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
// 每篇文章独立的标题与描述,用于浏览器标签页和分享卡片
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return { title: "文章不存在" };
return {
title: post.title,
description: post.excerpt,
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
const posts = getAllPosts();
const currentIndex = posts.findIndex((candidate) => candidate.slug === post.slug);
const prev = posts[currentIndex + 1];
const next = posts[currentIndex - 1];
const related = getRelatedPosts(post);
return (
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.date}>{formatDate(post.date)}</time>
<span>· {post.readingTime}</span>
<div>
{post.tags.map((tag) => (
<Link key={tag} href={`/tags/${encodeURIComponent(tag)}`}>
{tag}
</Link>
))}
</div>
</header>
<Prose content={post.content} />
<section>
<h2>评论区</h2>
<WalineComments path={`/posts/${post.slug}`} />
</section>
{related.length > 0 && (
<section>
<h2>相关文章</h2>
{related.map((candidate) => (
<Link key={candidate.slug} href={`/posts/${candidate.slug}`}>
{candidate.title}
</Link>
))}
</section>
)}
<nav>
{prev && <Link href={`/posts/${prev.slug}`}>← {prev.title}</Link>}
{next && <Link href={`/posts/${next.slug}`}>{next.title} →</Link>}
</nav>
</article>
);
}
三个关键点:
generateStaticParams:构建时告诉 Next.js 有哪些文章,每个都预渲染成 HTML,访问时秒开;generateMetadata:给每篇文章生成独立的标题和描述,利于 SEO 和分享;notFound():访问不存在的 slug 时直接进入 404 页面。
另外我在文章页顶部加了一个 ReadingProgress 组件,监听页面滚动,用一条细线显示阅读进度,属于锦上添花,几十行就能实现。
9. 标签、归档与搜索
这三个页面数据来源相同,只是展示角度不同。
标签:总览页用 getAllTags() 画一个标签云,按文章数排序。每个标签一个动态路由 app/tags/[tag]/page.tsx,同样用 generateStaticParams 预生成:
export async function generateStaticParams() {
return getAllTags().map((tag) => ({ tag: tag.name }));
}
// 页面内:
const posts = getPostsByTag(tag);
归档:把文章按年份分组,从新到旧列成时间线:
const years = [...new Set(posts.map((post) => post.date.slice(0, 4)))].sort(
(a, b) => b.localeCompare(a)
);
{years.map((year) => (
<section key={year}>
<h2>{year}</h2>
{posts
.filter((post) => post.date.startsWith(year))
.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`}>
{post.title}
</Link>
))}
</section>
))}
搜索:纯前端实现,不依赖任何搜索服务。思路是:构建时把所有文章转成纯文本作为索引,用户输入时在浏览器里过滤打分。打分规则很简单——标题命中加 10 分、标签命中加 4 分、正文命中加 1 分,按分数和日期排序,取前 20 条:
function search(index: SearchIndexItem[], query: string): SearchIndexItem[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return [];
return index
.map((item) => {
let score = 0;
if (item.title.toLowerCase().includes(normalized)) score += 10;
if (item.tags.some((tag) => tag.toLowerCase().includes(normalized))) score += 4;
if (item.text.toLowerCase().includes(normalized)) score += 1;
return { item, score };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || b.item.date.localeCompare(a.item.date))
.slice(0, 20)
.map((entry) => entry.item);
}
搜索页(服务端组件)负责构建索引,再用一个 "use client" 的搜索框组件接收输入、调用上面的函数、渲染结果。文章量不大时这种方案又快又省心。
10. RSS 与 sitemap
Next.js 的 Route Handler 可以直接输出 XML。RSS 放在 app/rss.xml/route.ts:
import { SITE } from "@/content/site";
import { getAllPosts, markdownToText } from "@/lib/posts";
export const dynamic = "force-dynamic";
function escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
export function GET() {
const posts = getAllPosts();
const items = posts
.slice(0, 20)
.map((post) => {
const url = `${SITE.url}/posts/${post.slug}`;
const pubDate = new Date(`${post.date}T00:00:00+08:00`).toUTCString();
const summary = escapeXml(markdownToText(post.content).slice(0, 500));
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${url}</link>
<guid>${url}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(post.excerpt)}</description>
<content:encoded><![CDATA[${summary}]]></content:encoded>
</item>`;
})
.join("");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE.title)}</title>
<link>${SITE.url}</link>
<description>${escapeXml(SITE.description)}</description>
<language>zh-CN</language>
${items}
</channel>
</rss>`;
return new Response(xml, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}
sitemap 更简单,Next.js 有内置约定,app/sitemap.ts 返回 URL 数组即可:
import type { MetadataRoute } from "next";
import { SITE } from "@/content/site";
import { getAllPosts } from "@/lib/posts";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
const staticPages: MetadataRoute.Sitemap = [
"",
"/about",
"/projects",
"/tags",
"/archive",
"/message-board",
"/search",
].map((path) => ({ url: `${SITE.url}${path}`, lastModified: now }));
const posts: MetadataRoute.Sitemap = getAllPosts().map((post) => ({
url: `${SITE.url}/posts/${post.slug}`,
lastModified: new Date(post.date),
}));
return [...staticPages, ...posts];
}
11. 评论区与留言板:接入 Waline
11.1 工作原理
Waline 由两部分组成:
- 服务端:一个独立部署的小服务(我用 Vercel 免费部署),负责接收和存储评论;
- 前端组件:
@waline/client,在博客页面上渲染评论框和评论列表。
评论数据存在服务端连接的 PostgreSQL 数据库里,不在博客代码仓库中。这意味着更新博客代码不会弄丢任何评论。
11.2 部署服务端
- 打开 Waline 官方文档,按“部署”指引用 Vercel 一键部署官方模板(会用你的 GitHub 账号授权,生成一个独立的服务项目,地址类似
xxx.vercel.app); - 按官方推荐申请一个免费的 Neon PostgreSQL 数据库,新建项目后复制连接串;
- 在 Neon 里执行官方仓库提供的建表 SQL(
assets/waline.pgsql),把表建好; - 回到 Vercel 的 Waline 项目 → Settings → Environment Variables,按官方文档添加环境变量(例如
DATABASE_URL填数据库连接串); - Deployments → Redeploy,让新配置生效;
- 打开
<服务地址>/ui/register注册第一个账号——第一个注册用户自动成为管理员,以后在/ui登录管理所有评论。
判断服务端是否部署成功的小技巧:访问 <服务地址>/ui/register,能正常打开注册页说明服务在工作;如果打开的是“页面不存在”,说明地址不对。
11.3 前端组件
components/WalineComments.tsx 是一个客户端组件,通过 path 参数区分当前页面(文章页传 /posts/xxx,留言板传 /message-board),这样不同页面有独立的评论区:
"use client";
import { useEffect, useRef } from "react";
import {
init,
type WalineInitOptions,
type WalineInstance,
} from "@waline/client";
import "@waline/client/style";
const serverURL = process.env.NEXT_PUBLIC_WALINE_SERVER_URL;
export function WalineComments({ path }: { path: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<WalineInstance | null>(null);
useEffect(() => {
if (!serverURL || !containerRef.current) return;
const options: WalineInitOptions = {
el: containerRef.current,
serverURL,
path,
lang: "zh-CN",
login: "enable",
pageSize: 10,
dark: false,
reaction: false,
emoji: [
"https://unpkg.com/@waline/emojis@1.4.0/weibo",
"https://unpkg.com/@waline/emojis@1.4.0/bilibili",
"https://unpkg.com/@waline/emojis@1.4.0/alus",
"https://unpkg.com/@waline/emojis@1.4.0/tieba",
"https://unpkg.com/@waline/emojis@1.4.0/qq",
],
};
instanceRef.current = init(options);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [path]);
if (!serverURL) {
return (
<div className="note">
评论服务尚未配置:请设置 NEXT_PUBLIC_WALINE_SERVER_URL 环境变量后重新部署。
</div>
);
}
return <div ref={containerRef} />;
}
然后把它放进文章详情页(path={/posts/${slug}})和留言板页面(path="/message-board")即可。
11.4 环境变量
在博客项目的 Vercel 设置里添加:
NEXT_PUBLIC_WALINE_SERVER_URL=https://你的-waline-服务.vercel.app
记得修改环境变量后要 Redeploy 才生效(这是最容易踩的坑)。本地没有配置这个变量时,页面会显示“评论服务尚未配置”的提示而不是报错,方便开发调试。
12. 样式
全站样式写在 app/globals.css。我的做法是先用 CSS 变量定义一套主题色和字体,再写各页面、组件的样式:
:root {
--bg: #f7f1e3; /* 页面背景 */
--card: #fdfaf3; /* 卡片背景 */
--ink: #33291c; /* 正文颜色 */
--accent: #d98e2b; /* 强调色 */
--line: #e2d6bd; /* 分隔线 */
--font-serif: "Songti SC", "STSong", "Noto Serif SC", Georgia, serif;
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
-apple-system, "Segoe UI", sans-serif;
--radius: 12px;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
line-height: 1.75;
}
想换主题时只需要改这几个变量,全站配色跟着变。评论区的外观也可以在 globals.css 里针对 .waline-* 的类名微调,让它和整体风格一致。
13. 部署上线
13.1 推到 GitHub
在 GitHub 新建一个仓库(公开或私有都可以——私有仓库不影响 Vercel 部署,源码不想公开的话尽管设为 Private),然后关联并推送:
git init
git add .
git commit -m "init: 个人博客"
git remote add origin https://github.com/你的账号/dcblog.git
git push -u origin main
13.2 接入 Vercel
- 打开 Vercel,用 GitHub 账号登录;
- Add New Project,选择刚才的仓库,导入;
- 框架选择 Next.js(Vercel 会自动识别),Environment Variables 里添加
NEXT_PUBLIC_WALINE_SERVER_URL; - 点 Deploy。等一两分钟,就能拿到
xxx.vercel.app的线上地址; - 以后每次
git push,Vercel 都会自动重新构建部署,不需要手动操作。
想绑定自己的域名,在项目的 Settings → Domains 里添加,按提示到域名服务商配置一条 CNAME 记录即可。
14. 日常写作流程
发一篇文章只需要三步:
- 在
content/posts/新建一个.md文件,填好 frontmatter; - 正文写 Markdown,插图放到
public/images/; git add . && git commit -m "写文章" && git push,网站自动更新。
推送前想先预览,运行 npm run dev 打开本地地址,或者跑 npm run build 确认构建通过。
进阶:用 Obsidian 写作。 我把博客目录直接作为 Obsidian 的库打开,并做了三件事:
- 附件目录设为
public/images,粘贴图片自动存进去; - 链接格式设为相对路径(配合第 6 节的路径修正插件,线上显示正常);
- 安装了免费的 Obsidian Git 插件,每 10 分钟自动“提交 + 推送”。
这样日常写作就是:打开 Obsidian → 写文章 → 保存。连命令行都不用碰,图片粘贴进去,到点自动发布。
15. 踩过的坑
把这些写下来,希望帮你少走弯路:
- Vercel 改了环境变量一定要 Redeploy,否则不生效;
- Waline 第一个注册的用户是管理员,管理后台在
<服务地址>/ui; - Waline 的 emoji 预设名要照官方文档写,比如
weibo、bilibili、alus、tieba、qq,写错会静默加载失败; - JSX 里英文撇号要写
',比如Derrian's Corner,直接写'会导致构建报错; - 评论数据在数据库里,不在代码仓库,删代码、换主题都不会丢评论,但备份数据库要单独做;
- 本地没有配评论服务地址是正常现象,页面会显示提示而不是报错,线上配好环境变量就正常。
结尾
到这里,一个从文章管理、页面渲染、搜索归档到评论互动的完整博客就跑起来了。全部基础设施免费,代码和内容都在自己手里,没有平台绑架。
往后可以按需加东西,比如:Waline 自带的阅读量统计、暗色模式、更多页面动效。这个项目会继续生长,我也会陆续把折腾过程写下来。
如果这篇文章对你有帮助,或者你用它搭出了自己的博客,欢迎在下面留言。
评论区