从零搭建 Next.js 博客全记录

nextjssupabasethreejstutorialreact

这是一个从零开始搭建个人博客的完整记录。技术栈:Next.js 16 + Supabase + Three.js + Tailwind CSS + MDX


目录

  1. 项目初始化
  2. 基础架构
  3. 主题系统
  4. Supabase 集成
  5. Three.js 3D 背景
  6. MDX 博客系统
  7. 评论系统
  8. 阅读统计
  9. AI 工具导航页
  10. SCL-90 心理测试
  11. 踩坑记录

项目初始化

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 Component
  • src/lib/supabase/server.ts — Server Component / Route Handler
  • src/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_URLNEXT_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(windowdocument),无法在 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.