从零搭建 Next.js 博客全记录
这是一个从零开始搭建个人博客的完整记录。技术栈:Next.js 16 + Supabase + Three.js + Tailwind CSS + MDX。
目录
项目初始化
npx create-next-app@latest next-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
Next.js 16 + React 19 起步,App Router + Turbopack。
npm install -D @types/node
npm install react-icons
目录结构:
next-blog/
├── src/
│ ├── app/ # App Router 页面
│ ├── components/ # 共享组件
│ ├── lib/ # 工具库
│ ├── data/ # 静态数据
│ └── types/ # TypeScript 类型
├── content/posts/ # MDX 博文
├── supabase/ # 数据库 Schema
└── public/ # 静态资源
基础架构
深色 / 浅色主题
使用 CSS 变量 + next-themes:
// src/components/ThemeProvider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
)
}
在 globals.css 中定义 CSS 变量,.light 和 .dark 两套色板。
Header / Footer
Header 包含 Logo + 导航链接 + 主题切换按钮。Footer 包含版权信息。
Proxy(Next.js 16)
Next.js 16 弃用 middleware.ts,改为 src/proxy.ts:
// src/proxy.ts
import type { NextRequest } from "next/server"
export function proxy(request: NextRequest) {
// 处理 cookie / session 刷新
}
Supabase 集成
安装依赖
npm install @supabase/supabase-js @supabase/ssr
客户端封装
三层结构:
src/lib/supabase/client.ts— 浏览器端 Client Componentsrc/lib/supabase/server.ts— Server Component / Route Handlersrc/lib/supabase/middleware.ts— Proxy / Middleware 用
.env.local 配置:
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
GitHub OAuth
// src/components/SessionWrapper.tsx
"use client"
import { createClient } from "@/lib/supabase/client"
import { useEffect } from "react"
export default function SessionWrapper() {
const supabase = createClient()
const signInWithGithub = async () => {
await supabase.auth.signInWithOAuth({
provider: "github",
options: { redirectTo: `${location.origin}/auth/callback` },
})
}
// ...
}
⚠️ 需要在 Supabase Dashboard → Authentication → Providers 中启用 GitHub,并填写 Client ID / Secret。
Three.js 3D 背景
安装
npm install three @react-three/fiber @react-three/drei
粒子系统
// src/components/ThreeBackground.tsx
"use client"
import { useRef, useMemo } from "react"
import { Canvas, useFrame } from "@react-three/fiber"
import type * as THREE from "three"
// 生成 1000 个随机粒子位置
const positions = new Float32Array(
Array.from({ length: 3000 }, () => (Math.random() - 0.5) * 10)
)
function Particles() {
const ref = useRef<THREE.Points>(null!)
useFrame((_, delta) => {
ref.current.rotation.y += delta * 0.02
})
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={1000}
array={positions}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial size={0.02} color="#3b82f6" transparent opacity={0.6} />
</points>
)
}
懒加载封装
Three.js 是客户端重量级依赖,用动态导入 + next/dynamic 包装:
// src/components/ThreeBackgroundWrapper.tsx
"use client"
import dynamic from "next/dynamic"
const ThreeBackground = dynamic(() => import("@/components/ThreeBackground"), {
ssr: false,
})
export default function ThreeBackgroundWrapper() {
return (
<div className="fixed inset-0 -z-10">
<ThreeBackground />
</div>
)
}
MDX 博客系统
安装
npm install gray-matter
npm install @next/mdx @mdx-js/loader @mdx-js/react
配置 next.config.mjs
import createMDX from "@next/mdx"
const withMDX = createMDX()
export default withMDX({
pageExtensions: ["ts", "tsx", "mdx"],
})
获取文章列表
// src/lib/posts.ts
import fs from "fs"
import path from "path"
import matter from "gray-matter"
export function getAllPosts() {
const fileNames = fs.readdirSync(postsDirectory)
return fileNames
.filter((fn) => fn.endsWith(".mdx"))
.map((fileName) => {
const source = fs.readFileSync(path.join(postsDirectory, fileName), "utf-8")
const { data } = matter(source)
return { slug: fileName.replace(/\.mdx$/, ""), ...data }
})
.sort((a, b) => new Date(b.date) - new Date(a.date))
}
文章 Frontmatter 格式
---
title: "文章标题"
description: "摘要"
date: "2026-06-28"
tags: ["nextjs", "react"]
featured: true # 置顶,可选
---
评论系统
Schema
create table comments (
id bigint generated by default as identity primary key,
post_slug text not null,
author_name text not null,
content text not null,
created_at timestamptz default now()
);
API Route
// src/app/api/comments/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const postSlug = searchParams.get("post_slug")
const supabase = await createClient()
const { data } = await supabase
.from("comments")
.select("*")
.eq("post_slug", postSlug)
.order("created_at", { ascending: false })
return Response.json(data)
}
评论组件
CommentSection 组件包含评论区列表 + 表单(名称、内容)。
阅读统计
Schema
create table views (
slug text primary key,
count bigint default 1
);
create or replace function increment_view(slug_param text)
returns void as $$
insert into views (slug, count) values (slug_param, 1)
on conflict (slug) do update set count = views.count + 1;
$$ language sql;
API Route
提供 GET 查询和 POST 递增两个端点:
// GET — 查询当前阅读数
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const slug = searchParams.get("slug")
const { data } = await supabase
.from("views").select("count").eq("slug", slug).single()
return Response.json({ count: data?.count ?? 0 })
}
// POST — 递增并返回新阅读数
export async function POST(request: NextRequest) {
const { slug } = await request.json()
await supabase.rpc("increment_view", { slug_text: slug })
const { data } = await supabase
.from("views").select("count").eq("slug", slug).single()
return Response.json({ count: data?.count ?? 1 })
}
ViewCounter 组件
每篇文章详情页嵌入,初次渲染时调用 POST /api/views 增加计数并显示:
export default function ViewCounter({ slug }: { slug: string }) {
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
fetch("/api/views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug }),
})
.then((res) => res.json())
.then((data) => setCount(data.count))
}, [slug])
if (count === null) return null
return <span>{count} views</span>
}
文章页面将 ViewCounter 放在标题区,与日期、标签同行显示。
AI 工具导航页
数据
放在 src/data/ai-tools.json,JSON 格式:
[
{
"name": "ChatGPT",
"description": "OpenAI 的对话式 AI",
"url": "https://chat.openai.com",
"category": "chat",
"icon": "🤖"
}
]
分类筛选
页面提供分类按钮(全部 / Chat / 编程 / 设计 / 写作 / 搜索),点击筛选。
组件
// src/components/ToolCard.tsx
export default function ToolCard({ tool }: { tool: AITool }) {
return (
<a href={tool.url} target="_blank" className="rounded-xl border p-4 transition hover:opacity-80">
<span className="text-2xl">{tool.icon}</span>
<h3 className="mt-2 font-semibold">{tool.name}</h3>
<p className="mt-1 text-sm">{tool.description}</p>
</a>
)
}
SCL-90 心理测试
数据
90 道题,10 个维度(躯体化、强迫、人际敏感等),5 级评分(1-5):
export const scl90Questions = [
{ id: 1, text: "头痛", dimension: "somatization" },
{ id: 2, text: "神经过敏,心中不踏实", dimension: "obsessive-compulsive" },
// ... 共 90 题
]
export const dimensions = {
somatization: { name: "躯体化", questionIds: [1, 4, 12, ...] },
// ...
}
UI
单卡片滑动切换(左右按钮或点击选项后自动前进),纯 CSS transition:
<div
className="transition-all duration-500 ease-in-out"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{questions.map((q) => (
<div key={q.id} className="w-full flex-shrink-0">
{/* 题目内容、选项按钮 */}
</div>
))}
</div>
结果计算
完成后展示各维度原始分、阳性项目数、阳性症状均分。用 radar-chart 可视化。
踩坑记录
Vercel 部署
🕳️ 部署 500 — proxy 缺少环境变量
问题:部署到 Vercel 后全站 500 错误。
原因:Next.js 16 的 proxy.ts 在每个请求都会执行,而它内部调用了 Supabase 的 createServerClient。如果 NEXT_PUBLIC_SUPABASE_URL 或 NEXT_PUBLIC_SUPABASE_ANON_KEY 未在 Vercel 环境变量中设置,process.env.xxx! 会得到 undefined,导致 proxy 崩溃,所有页面无法加载。
解决:在 proxy 入口处提前检查环境变量,缺失时跳过 Supabase 会话刷新:
export async function proxy(request: NextRequest) {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
return NextResponse.next()
}
return await updateSession(request)
}
同时在 Vercel 项目 → Settings → Environment Variables 添加对应变量。
🕳️ 推送后未自动部署
问题:代码推送到 GitHub 后,Vercel 没有触发自动部署。
原因:Vercel 项目可能是在 Dashboard 手动导入的,默认不连接 Git 仓库自动部署;或 GitHub App 未正确安装。
解决:手动触发部署:
Vercel Dashboard → 选择项目 → Deployments → Trigger Deployment → 选择 main 分支
或确保 Vercel 项目 Settings → Git 中已关联正确仓库,并开启 Auto-Deploy。
🕳️ CLI 无法交互式登录
问题:运行 npx vercel --prod 超时卡住。
原因:Vercel CLI 首次运行需要交互式登录(浏览器打开授权页面),在终端非交互模式下无法完成。
解决:
- 在本地安装并登录:
npm i -g vercel && vercel login - 或在 Vercel Dashboard 网页端直接操作(推荐)
Git 相关
🕳️ push 超时 / 认证失败
问题:git push 一直卡住或提示认证失败。
原因:未配置 GitHub 凭据,终端等待交互式输入用户名密码(而 GitHub 已不支持密码认证)。
解决:安装 GitHub CLI 并登录:
brew install gh && gh auth login
git push
或使用 Personal Access Token:
git remote set-url origin https://<username>:<token>@github.com/xxx/xxx.git
🕳️ push 被拒 — 远程有本地没有的提交
问题:git push 报错 Updates were rejected because the remote contains work that you do not have locally。
原因:远程仓库包含本地没有的 commits(例如 Vercel 或其他人先推送了代码),Git 拒绝直接覆盖。
解决:先拉取合并,再推送:
git pull --rebase origin main
git push
🕳️ Git 代理导致无法连接 GitHub
问题:git push 报错 Failed to connect to 127.0.0.1 port 7890: Couldn't connect to server。
原因:之前配置了 Git 全局代理(如 http.proxy=127.0.0.1:7890),但代理服务未启动,导致所有 Git 请求被拦截到无效地址。
解决:查看并清除代理配置:
git config --global --list | grep proxy # 查看是否有代理
git config --global --unset http.proxy # 清除 http 代理
git config --global --unset https.proxy # 清除 https 代理
🕳️ 自定义域名配置(Vercel + Cloudflare)
问题:想让博客通过自己的域名(如 jspeng.online)访问,Vercel + Cloudflare 如何配置。
Vercel 侧:
项目 → Settings → Domains → Add Domain
输入 jspeng.online → 勾选 "Also add www.jspeng.online"
→ 设置跳转规则(推荐 www 做主域名,apex 跳转到 www)
Cloudflare 侧(DNS 记录):
| 类型 | 名称 | 值 | 代理 |
|------|------|-----|------|
| A | @ | 76.76.21.21 | ⚫ DNS only |
| CNAME | www | cname.vercel-dns-0.com | ⚫ DNS only |
关键:两条记录都要点 灰色云(DNS only),不要橙色代理。否则 Vercel 会显示 Invalid Configuration,SSL 证书也可能异常。
若想保留 Cloudflare 加速,需在 Vercel 额外开启:
Vercel → Settings → Security → Verified Proxy → 启用
🕳️ 未关联 Git — 使用 Deploy Hook 替代
问题:Vercel 项目未关联 Git 仓库,git push 后不会自动部署。
原因:项目通过手动 vercel deploy 或 Dashboard 直接导入创建,未绑定 GitHub。
解决:两种方式:
- 推荐:关联 Git 仓库:Vercel Dashboard → Settings → Git → Connect Git Repository → 选择仓库 → 开启 Auto-Deploy
- 替代方案:Deploy Hook:在 Settings → Git → Deploy Hooks 创建 Hook,每次推送后手动触发:
curl -X POST https://api.vercel.com/v1/integrations/deploy/xxx/xxx
Next.js
🕳️ 构建缓存冲突
问题:更新代码后本地启动报 "Cannot find module" 错误。
原因:Next.js 的 .next 缓存了旧的编译产物,版本升级或文件变动后缓存未失效。
解决:
rm -rf .next && npm run dev
建议在 package.json 中添加快捷命令:
"dev:clean": "rm -rf .next && next dev"
🕳️ Three.js 服务端渲染报错
问题:Three.js 组件在构建时报 window is not defined。
原因:Three.js 依赖浏览器 API(window、document),无法在 Node.js 服务端渲染。
解决:用 next/dynamic 动态导入并关闭 SSR:
const ThreeBackground = dynamic(() => import("@/components/ThreeBackground"), {
ssr: false,
})
🕳️ middleware → proxy 迁移
问题:Next.js 16 启动报错,找不到 middleware.ts。
原因:Next.js 16 弃用了 middleware.ts,改为 src/proxy.ts。
解决:将 middleware.ts 重命名为 src/proxy.ts,导出签名不变:
// src/proxy.ts — Next.js 16 替代 middleware.ts
export async function proxy(request: NextRequest) {
return await updateSession(request)
}
Supabase
🕳️ @supabase/ssr v0.12 setAll 签名变更
问题:@supabase/ssr 升级到 v0.12 后 setAll 方法报类型错误。
原因:v0.12 新增了第二个参数 headers,旧的签名 (cookiesToSet) => void 不再匹配。
解决:更新为 setAll(cookiesToSet, headers) => { ... },并在函数体内处理 headers:
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
if (headers) {
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
}
}
🕳️ 匿名写入被拒
问题:评论发表或阅读计数增加时报 401 / 403。
原因:Supabase 默认所有表只允许认证用户操作,匿名写入需要显式开启 Row Level Security (RLS) 策略。
解决:在 Supabase Dashboard → SQL Editor 中执行:
create policy "允许匿名插入评论"
on comments for insert
to anon
with check (true);
create policy "允许匿名读取评论"
on comments for select
to anon
using (true);
views 表同理。
总结
这个博客项目涵盖了现代前端开发的主要技术点:
| 技术 | 用途 | |------|------| | Next.js 16 App Router | 服务端组件、路由 | | Supabase | 认证、数据库、API | | Three.js + R3F | 3D 粒子背景 | | Tailwind CSS | 样式系统 | | MDX | 内容管理 | | next-themes | 暗黑模式 |
整个项目源代码开放在 GitHub 上,欢迎查看和 Star。
Happy coding! 🚀
Comments
Sign in to leave a comment.