11 8 月, 2026
0 Comments
1 category
Next.js 项目架构设计与最佳实践
Next.js 已成为 React 生态中最流行的全栈框架之一。但当一个项目从 Demo 走向生产,合理的架构设计就成了决定项目可维护性的关键。本文将分享一套经过实战验证的 Next.js 项目架构方案。
为什么架构设计如此重要?
很多开发者在初学 Next.js 时,会直接把所有代码塞进 app/ 目录。这在项目初期看起来没什么问题,但随着功能迭代,问题会逐一浮现:
- 业务逻辑散落在 Server Component、Route Handler 和 Client Component 中
- 类型定义四处重复,改一处漏十处
- API 调用缺乏统一的错误处理,到处
try-catch - 状态管理混乱,服务端数据和客户端状态混在一起
好的架构不是为了”炫技”,而是让团队能以最小的认知成本理解和修改代码。
推荐的目录结构
src/
├── app/ # Next.js App Router(仅路由与页面)
│ ├── (auth)/ # 路由组:认证相关
│ ├── (dashboard)/ # 路由组:后台管理
│ ├── api/ # API Route Handlers
│ └── layout.tsx
├── components/ # UI 组件
│ ├── ui/ # 原子组件(Button, Input, Modal...)
│ └── features/ # 功能组件(UserProfile, PostCard...)
├── features/ # 按业务领域组织
│ ├── auth/
│ │ ├── actions.ts # Server Actions
│ │ ├── services.ts # 业务逻辑
│ │ └── types.ts # 领域类型
│ └── posts/
│ ├── components/ # 领域内组件
│ ├── hooks/ # 领域内 Hook
│ ├── services.ts
│ └── types.ts
├── lib/ # 跨领域工具
│ ├── db.ts # 数据库客户端
│ ├── auth.ts # 认证配置
│ └── utils.ts
├── hooks/ # 全局 Hooks
├── types/ # 全局类型
└── config/ # 配置文件
这个结构遵循了分层 + 领域驱动的思路:
app/只负责路由映射和页面组合,不含业务逻辑components/ui/是无业务感知的纯 UI 组件features/按业务领域封装,每个 feature 内聚自己的 actions、services、types
Server Actions 的正确用法
Server Actions 是 Next.js 13+ 提供的一个强大特性,但滥用会导致代码难以测试和复用。建议遵循以下原则:
1. Actions 只做编排,不写业务逻辑
// ❌ 坏实践:Action 里塞了太多逻辑
// features/posts/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
if (!title || title.length < 3) {
throw new Error('标题不能少于3个字符')
}
// 验证、数据清洗、数据库操作全混在这里...
}
// ✅ 好实践:Action 只做编排
// features/posts/actions.ts
'use server'
import { createPost } from './services'
import { CreatePostSchema } from './types'
export async function handleCreatePost(formData: FormData) {
const raw = Object.fromEntries(formData)
const parsed = CreatePostSchema.parse(raw)
return createPost(parsed)
}
2. 用 Zod 做输入校验,不信任任何客户端数据
// features/posts/types.ts
import { z } from 'zod'
export const CreatePostSchema = z.object({
title: z.string().min(3, '标题至少3个字符').max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(10).default([]),
published: z.boolean().default(false),
})
export type CreatePostInput = z.infer<typeof CreatePostSchema>
3. 统一错误处理
// lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public status: number = 400
) {
super(message)
}
}
// features/posts/services.ts
export async function createPost(input: CreatePostInput) {
const user = await getCurrentUser()
if (!user) {
throw new AppError('未登录', 'UNAUTHORIZED', 401)
}
// 使用事务确保数据一致性
const post = await db.$transaction(async (tx) => {
const post = await tx.post.create({
data: { ...input, authorId: user.id },
})
await tx.activityLog.create({
data: { userId: user.id, action: 'CREATE_POST', targetId: post.id },
})
return post
})
return post
}
数据获取策略:RSC + React Query
生产环境中,建议混合使用 Server Components 和 React Query:
- 首屏数据:使用 Server Component 在服务端获取,充分利用 SSR
- 客户端交互数据:使用 React Query 做缓存和乐观更新
- 实时数据:在 Client Component 中使用 React Query 配合轮询或 WebSocket
// app/(dashboard)/posts/page.tsx (Server Component)
import { listPosts } from '@/features/posts/services'
import { PostListClient } from './PostListClient'
export default async function PostsPage() {
const initialPosts = await listPosts() // 服务端获取首屏数据
return <PostListClient initialData={initialPosts} />
}
// PostListClient.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
export function PostListClient({ initialData }: { initialData: Post[] }) {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
initialData, // Hydrate 服务端数据到客户端
})
return (
<div className="grid gap-4">
{posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
)
}
环境变量与配置管理
Next.js 的环境变量管理有其特殊之处。关键在于区分前缀:
# .env.local
# 服务端可访问
DATABASE_URL="postgresql://..."
API_SECRET="sk-xxx"
# 客户端可访问(必须有 NEXT_PUBLIC_ 前缀)
NEXT_PUBLIC_SITE_URL="https://example.com"
NEXT_PUBLIC_GA_ID="G-XXXXXXX"
// config/site.ts
export const siteConfig = {
name: 'My App',
url: process.env.NEXT_PUBLIC_SITE_URL!,
// 运行时校验,避免启动后才发现缺失
...(function validate() {
if (!process.env.NEXT_PUBLIC_SITE_URL) {
throw new Error('NEXT_PUBLIC_SITE_URL is required')
}
return {}
})(),
} as const
性能优化清单
以下是生产部署前必查的几个点:
1. 图片优化
import Image from 'next/image'
// ✅ 使用 next/image 而非原生 <img>
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={450}
priority={index === 0} // 首屏图片优先加载
placeholder="blur" // 模糊占位
blurDataURL={post.blurHash}
/>
2. 动态导入,减少首屏 JS 体积
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/features/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // 仅在客户端渲染的重型组件
})
3. Route Segment Config 细粒度控制
// 静态生成的博客页面
export const dynamic = 'force-static'
export const revalidate = 3600 // ISR: 每小时重新生成
// 实时数据看板
export const dynamic = 'force-dynamic' // SSR always
测试策略
features/posts/
├── __tests__/
│ ├── services.test.ts # 单元测试(纯逻辑)
│ ├── actions.test.ts # 集成测试(含数据库)
│ └── page.test.tsx # E2E / 组件测试
- services 使用 Vitest,纯函数测试,Mock 外部依赖
- actions 配合测试数据库做集成测试
- 页面 用 Playwright 做 E2E
// features/posts/__tests__/services.test.ts
import { describe, it, expect, vi } from 'vitest'
describe('createPost', () => {
it('should reject when user is not authenticated', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
await expect(createPost(mockInput)).rejects.toThrow('未登录')
})
})
总结
一个健康的 Next.js 项目架构,核心是关注点分离:
| 层级 | 职责 | 示例 |
|---|---|---|
app/ |
路由映射 | 页面入口、Layout、Metadata |
features/ |
业务逻辑 | Services、Actions、领域类型 |
components/ui/ |
纯 UI | Button、Modal、Table |
lib/ |
基础设施 | DB、Auth、工具函数 |
遵循这些实践,项目从 3 个页面扩展到 30 个页面时,代码结构依然清晰可控。这不是教条,而是一线团队从坑里爬出来之后沉淀的经验。希望对你有所帮助。
本文发表于 .:品味:.,转载请注明出处。
Category: fullstack