🌳Derrian's Corner

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 里直接返回 nullgetAllPosts 会过滤掉;
  • 所有函数每次调用都会重新读一遍文件。文章量小(几十篇)时完全够用,没必要引入缓存。

6. 图片路径:解决本地编辑器的相对路径问题

在本地 Markdown 编辑器里粘贴图片,通常会生成类似 ../../public/images/xxx.png 的相对路径。本地看没问题,部署到线上后路径就错了。解决办法是写两个小插件,在渲染前把这类路径统一改写为 /images/xxx.pnglib/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>
  );
}

HeaderFooter 是两个简单组件,内部放一个导航数组循环渲染即可(首页、关于、项目、标签、归档、留言板、搜索)。

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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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 部署服务端

  1. 打开 Waline 官方文档,按“部署”指引用 Vercel 一键部署官方模板(会用你的 GitHub 账号授权,生成一个独立的服务项目,地址类似 xxx.vercel.app);
  2. 按官方推荐申请一个免费的 Neon PostgreSQL 数据库,新建项目后复制连接串;
  3. 在 Neon 里执行官方仓库提供的建表 SQL(assets/waline.pgsql),把表建好;
  4. 回到 Vercel 的 Waline 项目 → Settings → Environment Variables,按官方文档添加环境变量(例如 DATABASE_URL 填数据库连接串);
  5. Deployments → Redeploy,让新配置生效;
  6. 打开 <服务地址>/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

  1. 打开 Vercel,用 GitHub 账号登录;
  2. Add New Project,选择刚才的仓库,导入;
  3. 框架选择 Next.js(Vercel 会自动识别),Environment Variables 里添加 NEXT_PUBLIC_WALINE_SERVER_URL
  4. Deploy。等一两分钟,就能拿到 xxx.vercel.app 的线上地址;
  5. 以后每次 git push,Vercel 都会自动重新构建部署,不需要手动操作。

想绑定自己的域名,在项目的 Settings → Domains 里添加,按提示到域名服务商配置一条 CNAME 记录即可。

14. 日常写作流程

发一篇文章只需要三步:

  1. content/posts/ 新建一个 .md 文件,填好 frontmatter;
  2. 正文写 Markdown,插图放到 public/images/
  3. git add . && git commit -m "写文章" && git push,网站自动更新。

推送前想先预览,运行 npm run dev 打开本地地址,或者跑 npm run build 确认构建通过。

进阶:用 Obsidian 写作。 我把博客目录直接作为 Obsidian 的库打开,并做了三件事:

  • 附件目录设为 public/images,粘贴图片自动存进去;
  • 链接格式设为相对路径(配合第 6 节的路径修正插件,线上显示正常);
  • 安装了免费的 Obsidian Git 插件,每 10 分钟自动“提交 + 推送”。

这样日常写作就是:打开 Obsidian → 写文章 → 保存。连命令行都不用碰,图片粘贴进去,到点自动发布。

15. 踩过的坑

把这些写下来,希望帮你少走弯路:

  1. Vercel 改了环境变量一定要 Redeploy,否则不生效;
  2. Waline 第一个注册的用户是管理员,管理后台在 <服务地址>/ui
  3. Waline 的 emoji 预设名要照官方文档写,比如 weibobilibilialustiebaqq,写错会静默加载失败;
  4. JSX 里英文撇号要写 &apos;,比如 Derrian&apos;s Corner,直接写 ' 会导致构建报错;
  5. 评论数据在数据库里,不在代码仓库,删代码、换主题都不会丢评论,但备份数据库要单独做;
  6. 本地没有配评论服务地址是正常现象,页面会显示提示而不是报错,线上配好环境变量就正常。

结尾

到这里,一个从文章管理、页面渲染、搜索归档到评论互动的完整博客就跑起来了。全部基础设施免费,代码和内容都在自己手里,没有平台绑架。

往后可以按需加东西,比如:Waline 自带的阅读量统计、暗色模式、更多页面动效。这个项目会继续生长,我也会陆续把折腾过程写下来。

如果这篇文章对你有帮助,或者你用它搭出了自己的博客,欢迎在下面留言。

评论区

相关文章