从零到上线:我的 Next.js 最小化个人博客实践

7/23/2025, 7:38:27 PM

本文记录了我从搭建最小可用博客到完成部署的全过程,包括技术选型、博客功能实现(首页、详情页、编辑页、权限管理)、图片粘贴上传、以及服务器部署与自动化构建。适合想快速实践个人博客全栈开发的人参考。

输出倒逼输入

项目之所以要开源,科研之所以要分享,是因为经得起推敲的知识才是好知识。本着这样的观念,我决定开始写博客分享我所学的知识。

博客框架还不够

借着这次机会,我决定先从个人搭建博客最小实现开始,一步步添加我需要的功能。同时练习一下我没做过的部署环节。

我是怎么做的

技术选型

框架方面就选择 next.js,成熟的 ssr 所带来的 SEO 优化和首屏加载速度提升是对我这种内容型网站有利的,同时是个全栈框架可以把我简易的后端也写在里面。剩下的工具由 chatgpt 来推荐就好,基本上都是周下载十几万以上的开源库。不用担心 bug 或者遇到问题了没地方搜和问。

以下列出最小实现博客所用到的库:

  • prisma:ORM 工具
  • mysql:数据库
  • @uiw/react-markdown-preview:markdown 预览
  • @uiw/react-md-editor:在线编辑 markdown
  • remark & rehype:解析 markdown 至 html
  • highlight.js:代码高亮
  • gray-matter:解析 markdown 元数据

搭建博客

博客首页

目前只是调用接口后遍历渲染数据,后期可以做的漂亮一点吸引访客兴趣。

详情页

因为我们是以 markdown 格式存储的,这里用 remark 来方便地将 markdown 格式转化成对应的 html 结构,同时添加代码高亮。

const processedContent = await remark()
  .use(remarkRehype)
  .use(rehypeHighlight)
  .use(rehypeStringify)
  .process(post.content);
const contentHtml = processedContent.toString();

编辑页

以往使用博客框架的时候都是在编辑器里面写 markdown 的,这次增加一个编辑页让写作体验更完整(更多的使用 web)。

实现代码较多的是粘贴图片的功能

/*
监听粘贴事件,触发后遍历 e.clipboardData.items,
如果 item 类型为图片则用 getAsFile 获取它的文件,
调用接口上传至数据库,并把返回的 url 插入到文章中
*/
useEffect(() => {
  const handler = async (e) => {
    /*
    clipboardEvent 虽然被 mdn 标为实验性,
    但 caniuse 显示主流浏览器支持度达 98.82%
    */
    if (!e.clipboardData) return
    const items = e.clipboardData.items

    for (const item of items) {
      if (item.type.startsWith('image/')) {
        const file = item.getAsFile()
        if (file) {
          const url = await uploadImage(file)
          insertAtCursor(`![image](${url})`)
        }
      }
    }
  }

  const container = editorRef.current
  container?.addEventListener('paste', handler)

  return () => {
    container?.removeEventListener('paste', handler)
  }
}, [])

// 用 FormData 来提交二进制文件
const uploadImage = async (file) => {
  const formData = new FormData()
  formData.append('file', file)

  const res = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  })
  const data = await res.json()
  return data.url
}

const insertAtCursor = (text) => {
  const textarea = document.querySelector(
    '.w-md-editor-text-input'
  )
  if (!textarea) return

  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  setContent((prevContent) => {
    const before = prevContent.substring(0, start);
    const after = prevContent.substring(end);
    return before + text + after;
  });

  // 移动光标
  requestAnimationFrame(() => {
    textarea.focus()
    textarea.selectionStart = textarea.selectionEnd = start + text.length
  })
}

编辑页同时承担添加和修改文章的职责,所以要用 useSearchParams 加以区分,如果找到 id 就回填数据,编辑完后发送 put 请求。如果没找到 id 就显示空编辑框,写完后发送 post 请求。

权限管理

因为后台也写在项目里,所以要做权限管理。要关注的方面有:

token 签发

if (!user || !bcrypt.compareSync(password, user.password)) {
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}

const token = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET,
  {
    expiresIn: '7d',
  }
)

const res = NextResponse.json({ message: 'Login success' })
res.cookies.set('token', token, {
  httpOnly: true,
  path: '/',
  secure: process.env.NODE_ENV === 'production',
  maxAge: 60 * 60 * 24 * 7,
})
禁止游客访达无需访达的页面
const token = request.cookies.get("token")?.value;
if (!token) {
  return NextResponse.redirect(new URL("/login", request.url));
}

try {
  await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
  return NextResponse.next();
} catch (err) {
  return NextResponse.redirect(new URL("/login", request.url));
}
向游客隐藏他们用不到的按钮
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
let isAuthenticated = false;

if (token) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    isAuthenticated = !!decoded?.userId;
  } catch (e) {
    // token 失效或伪造
  }
}
return isAuthenticated;
后端添加鉴权逻辑
const token = req.cookies.get("token")?.value;
if (!token) {
  return NextResponse.json({ error: "未授权:缺少 token" }, { status: 401 });
}

try {
  jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
  return NextResponse.json({ error: "未授权:token 无效" }, { status: 401 });
}

到这里博客的最小实现就完成了,我可以在网页上写 markdown 语言,预览博客效果,发布到数据库,并在首页访问到。我现在就在用文章发布页写这份博客,体验很不错。

部署上线

首次部署

所用服务器系统为 Ubuntu 22.04,通过命令行来完成服务器初始化和项目部署。

# 连接至服务器
ssh root@xxx

# 更新包
apt update && apt upgrade -y

# 安装 Node.js
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
\. "$HOME/.nvm/nvm.sh"
nvm install 22.14.0

# 安装 PM2(进程管理器,保持Next.js项目常驻)
npm install -g pm2

# 安装 Nginx(反向代理服务器)
apt install -y nginx

# 安装 Git
apt install -y git

# 安装项目(记得绑定 SSH)
cd /var/www
git clone xxx
cd xxx
npm install
npx prisma migrate deploy
npx prisma generate
npm run build

# 用 pm2 来管理进程
pm2 start npm --name "my-blog" -- start
pm2 startup
pm2 save

# 配置 nginx
nano /etc/nginx/sites-available/default

# 监听 443 端口,host 匹配 server_name,
# path 匹配 location,执行对应代码块内容
# server {
#     listen 80;
#     server_name linhanxi.cn www.linhanxi.cn;
#     return 301 https://$host$request_uri;
# }
#
# server {
#     listen 443 ssl;
#     server_name linhanxi.cn www.linhanxi.cn;
#
#     ssl_certificate /etc/nginx/ssl/linhanxi.cn.pem;
#     ssl_certificate_key /etc/nginx/ssl/linhanxi.cn.key;
#
#     ssl_protocols TLSv1.2 TLSv1.3;
#     ssl_ciphers HIGH:!aNULL:!MD5;
#
#     location / {
#         proxy_pass http://localhost:3000;
#         proxy_http_version 1.1;
#         proxy_set_header Upgrade $http_upgrade;
#         proxy_set_header Connection 'upgrade';
#         proxy_set_header Host $host;
#         proxy_cache_bypass $http_upgrade;
#     }
# }

nginx -t   # 测试配置是否正确
systemctl restart nginx

# 配置防火墙,开放 22 / 80 / 443 端口
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable

至此网站已经可以正常访问了!

自动化部署

上式构建项目的代码在每次项目更新后都要再执行一次,非常麻烦,让我们用 github actions + 部署脚本来实现推送代码到仓库后自动构建。

# 先在服务器写好一份构建脚本
nano /var/www/deploy.sh

# #!/bin/bash
# cd /var/www/hx-blog
# git pull
# npm install
# npx prisma migrate deploy
# npx prisma generate
# npm run build
# pm2 restart my-blog

# 赋予执行权限
chmod +x /var/www/deploy.sh

然后在项目根目录下创建 /.github/workflows/deploy.yml

name: Deploy to Aliyun

on:
  push:
    branches:
      - master # 当 push 到 master 分支时触发

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 连接到阿里云服务器并执行部署脚本
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_KEY }}
          script: |
            bash /var/www/deploy.sh

接着在线上仓库 -> Settings -> Secrets and variables -> Actions 添加上式所需 secrets。

  • SERVER_HOST:服务器 ip
  • SERVER_USER:登录用户名
  • SERVER_KEY:私钥

这里用到 SSH 公钥登录,所以要先在本地生成密钥后,把私钥存进 SERVER_KEY,公钥写入服务器的 ~/.ssh/authorized_keys

此时,再推送代码到线上仓库时就会自动化部署了!

我收获了什么

  • 一个可以随心所欲扩建的博客;
  • 理解了上线的基本流程;

写在最后

项目源码:https://github.com/hannnnxiiii/hx-blog

致谢:chatgpt —— 不求回报的后端 er