博客系统的管理后台需要处理文章的增删改查、评论审核、文件上传、系统监控等功能。这类界面有一个共同特点:大量表单 + 大量数据展示 + 频繁的服务端交互。
本文记录我在管理后台中选用的技术栈,以及每个选型背后的理由。
| 层级 | 技术 | 版本 | 用途 |
|---|---|---|---|
| 框架 | Next.js | 15 | App Router、SSR/SSG |
| UI | React | 19 | 组件模型 |
| 语言 | TypeScript | 5 | 类型安全 |
| 样式 | Tailwind CSS | v4 | 原子化 CSS |
| 组件库 | Shadcn/UI | latest | 可定制 UI 组件 |
| 服务端状态 | TanStack Query | v5 | 数据获取与缓存 |
| 客户端状态 | Zustand | v5 | 轻量全局状态 |
| 表单验证 | Zod | v3 | Schema 驱动验证 |
Next.js 15 的 App Router 默认所有组件都是服务端组件(Server Component),只有需要交互时才用 'use client'。
管理后台的页面基本都有表单、按钮点击、搜索等交互,所以大多数组件都需要标注 'use client'。但页面的骨架和布局(侧边栏、导航)可以保持服务端组件,减少客户端 JS 体积。
// app/(dashboard)/layout.tsx — 服务端组件(纯结构,无交互)
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar /> {/* 服务端渲染的侧边栏 */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}
// components/features/ArticleTable.tsx — 客户端组件(有数据获取和搜索交互)
'use client'
export default function ArticleTable() {
const [search, setSearch] = useState('')
const { data, isLoading } = useArticles({ search })
// ...
}
**路由分组(Route Groups)**兼顾认证和布局隔离:
app/
├── (auth)/ # 认证页面组(独立布局,不显示侧边栏)
│ ├── login/page.tsx
│ └── layout.tsx
└── (dashboard)/ # 后台管理组(有侧边栏的布局)
├── articles/page.tsx
├── comments/page.tsx
└── layout.tsx
括号括起来的目录名不会出现在 URL 里,(auth) 和 (dashboard) 只是逻辑分组,对应不同的 layout.tsx。
为什么不直接用 useEffect + useState?
自己管理服务端状态,每个数据请求都需要写:加载状态、错误状态、重试逻辑、缓存、失效刷新……代码量大且容易遗漏场景。TanStack Query 把这些通用逻辑封装好了。
// hooks/useDashboardStats.ts
import { useQuery } from '@tanstack/react-query'
export function useDashboardStats() {
// 业务指标(Micrometer)
const { data: metrics, isLoading: metricsLoading } = useQuery({
queryKey: ['dashboard', 'metrics'],
queryFn: getDashboardMetrics,
staleTime: 1000 * 60, // 1 分钟内不重新请求
refetchInterval: 1000 * 30, // 每 30 秒自动轮询刷新
})
// 系统健康状态
const { data: healthData, isLoading: healthLoading } = useQuery({
queryKey: ['dashboard', 'health'],
queryFn: getHealth,
staleTime: 1000 * 30,
refetchInterval: 1000 * 60,
})
return { metrics, healthData, isLoading: metricsLoading || healthLoading }
}
queryKey 是缓存的 Key,TanStack Query 以此判断是否复用缓存:
['dashboard', 'metrics'] — 全局共享这个缓存,任何组件用同一 key 不会重复请求['articles', { page: 1, search: 'java' }] — 携带参数的查询,参数不同时自动区分缓存增删改操作用 useMutation,成功后通过 invalidateQueries 让相关缓存失效,触发自动重新拉取,UI 自动更新:
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteArticle(id),
onSuccess: () => {
// 标记文章列表缓存为过期,触发重新请求
queryClient.invalidateQueries({ queryKey: ['articles'] })
toast({ title: '删除成功' })
},
onError: (error) => {
toast({ title: '删除失败', description: error.message, variant: 'destructive' })
}
})
这个模式保证了乐观 UI的正确性:不需要手动维护本地列表状态,让 Query 的缓存成为单一数据源。
服务端状态交给 TanStack Query,客户端的全局 UI 状态(用户信息、登录状态)用 Zustand 管理。
// stores/useAuthStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
user: User | null
token: string | null
tokenExpiresAt: number | null
isAuthenticated: boolean
login: (user: User, token: string, expiresIn: number) => void
logout: () => Promise<void>
isTokenExpired: () => boolean
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
tokenExpiresAt: null,
isAuthenticated: false,
login: (user, token, expiresIn) => {
const expiresAt = Date.now() + expiresIn * 1000
localStorage.setItem('token', token)
Cookies.set('token', token, { expires: 1 })
set({ user, token, tokenExpiresAt: expiresAt, isAuthenticated: true })
},
logout: async () => {
try {
await apiLogout() // 调用后端让 Token 失效
} finally {
localStorage.removeItem('token')
Cookies.remove('token')
set({ user: null, token: null, isAuthenticated: false })
}
},
isTokenExpired: () => {
const { tokenExpiresAt } = get()
if (!tokenExpiresAt) return true
return Date.now() > tokenExpiresAt
},
}),
{
name: 'auth-storage',
// 只持久化必要字段,避免存入 action 函数
partialize: (state) => ({
user: state.user,
token: state.token,
tokenExpiresAt: state.tokenExpiresAt,
isAuthenticated: state.isAuthenticated,
}),
}
)
)
几个设计细节:
localStorage 供客户端 JS 读取,Cookie 供 Next.js Middleware 做路由保护(服务端可读 Cookie,读不到 localStorage)tokenExpiresAt,客户端可以在请求前提前检测过期,避免用过期 Token 发请求再等 401 响应partialize:persist 中间件默认会序列化整个 state,但函数无法序列化,partialize 声明只持久化数据字段为什么用 Zustand 而不是 Context + useReducer?
const user = useAuthStore(state => state.user) 只有 user 变化时该组件才渲染表单验证的核心原则:中心化定义,自动推导类型。
// lib/validations.ts
import { z } from 'zod'
export const ArticleSchema = z.object({
title: z.string()
.min(1, '标题不能为空')
.max(255, '标题最多255字符'),
summary: z.string()
.max(500, '摘要最多500字符')
.optional()
.or(z.literal('')), // 允许空字符串(可选字段常见需求)
content: z.string()
.min(10, '内容至少10字符'),
categoryId: z.string().min(1, '请选择分类'),
tagIds: z.array(z.string())
.max(5, '最多选择5个标签')
.default([]),
type: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(1),
isTop: z.union([z.literal(0), z.literal(1)]).default(0),
})
// 从 Schema 推导出 TypeScript 类型,无需重复定义
export type ArticleFormData = z.infer<typeof ArticleSchema>
最大的好处:Schema 定义一次,TypeScript 类型自动推导,不需要维护两份类型定义:
// ❌ 旧做法:手动维护类型和验证规则两份代码
type ArticleFormData = { title: string; content: string; ... }
function validate(data: ArticleFormData) { /* 手写验证逻辑 */ }
// ✅ 新做法:Schema 是唯一数据源
export type ArticleFormData = z.infer<typeof ArticleSchema>
// 修改 Schema 时,类型自动同步,不会出现类型和验证不一致的 Bug
Shadcn/UI 的定位和传统组件库不同——它不是一个 npm 包,而是把组件源码复制到你的项目里。
npx shadcn@latest add table
npx shadcn@latest add form
npx shadcn@latest add dialog
执行后,组件代码直接出现在 components/ui/ 目录下,完全可修改、完全可控。
这个模式的优势:
管理后台使用了大约 30 个 Shadcn/UI 组件,覆盖 Button、Input、Table、Form、Dialog、Select、Badge、Tabs 等所有常见场景。
管理后台的状态被清晰地分成两类:
全局状态
├── 服务端状态(TanStack Query 管理)
│ ├── 文章列表、文章详情
│ ├── 评论列表
│ ├── 文件列表
│ └── 系统监控指标(定时轮询)
│
└── 客户端状态(Zustand 管理)
├── 用户认证状态(isAuthenticated、user、token)
└── UI 偏好(侧边栏折叠)
这个划分的原则:数据来自服务端的,让 TanStack Query 管;纯客户端 UI 状态,让 Zustand 管。两者不要混用,否则会出现服务端数据和本地状态不同步的问题。
这套技术栈经过实际项目验证,在开发效率和代码质量上都有明显收益:
| 问题 | 解决方案 |
|---|---|
| 复杂的数据获取、缓存、刷新逻辑 | TanStack Query |
| 全局认证状态、持久化 | Zustand + persist 中间件 |
| 表单验证与类型同步 | Zod Schema 推导 |
| UI 组件定制性不足 | Shadcn/UI(源码级可控) |
| 客户端/服务端组件的边界 | App Router 路由分组 |
Next.js 15 + TypeScript 的 App Router 模型提供了清晰的服务端/客户端边界——大部分布局结构由服务端渲染,只有真正需要交互的"叶子组件"才是客户端组件。这个架构决策让管理后台在保持开发便利的同时,也有不错的初始加载性能。
在设计个人博客后端时,我面临一个经典抉择:用简单粗暴的单体应用,还是拥抱时髦的微服务?最终我选择了两者之间的 模块化单体架构(Modular Monolith),事实证明这是正确的决定。 为什么不用微服务? 微服务固然优秀,但对于个人项目来说成本太高: - 需要服务注册、配置中心、网关、链路追踪…… - 每个服务独立部署,运维复杂度指数级增长 - 分布式事务难以处理 - 单人开发,维护多个仓库效率...
在做个人博客后端时,我发现每个业务模块都在重复同样的代码:创建实体、校验参数、调用 Mapper、处理异常……于是我设计了 BaseServiceImpl,一个基于 MyBatis-Plus 和 MapStruct 的通用 CRUD 基类。
作为一个人项目,我的博客系统包含 4 个独立仓库:后端(Spring Boot)、前端(Next.js 博客展示)、管理后台(Next.js Admin)、部署配置(Docker Compose)。最初我每次更新代码都要手动构建镜像、推送 Docker Hub、SSH 到 VPS 拉取最新版——这套流程繁琐且容易出错。
从数据库到用户界面,这篇文章记录我在个人博客系统中每一层技术选型背后的思考。整个系统包含 4 个独立仓库,涵盖 Java 后端、两个 Next.js 前端、和 Docker 部署配置。 --- 一、整体架构图 ` 用户浏览器 │ ▼ Caddy(反向代理 + 自动 HTTPS) │ ├── chonkybird.com → Next.js 博客前端(SSR) ├── admin.
这篇文章记录我在开发个人博客系统全程中使用 AI 工具的真实体验——包括它在哪些环节显著提升了效率,在哪些地方需要谨慎,以及如何正确地使用 AI。 整个系统包含 Spring Boot 后端、两个 Next.js 前端、Docker Compose 部署配置,共 4 个独立仓库,全程有 AI 深度介入。