适用版本:Next.js 15.x | Node.js ≥20.x | React 19
目录
- [Next.js 15 新特性一览](#Next.js 15 新特性一览)
- 项目初始化与架构设计
- [App Router vs Pages Router 深度对比](#App Router vs Pages Router 深度对比)
- [Server Components 与 Client Components 最佳实践](#Server Components 与 Client Components 最佳实践)
- [Middleware 路由中间件](#Middleware 路由中间件)
- [Server Actions 表单处理](#Server Actions 表单处理)
- [数据库集成:Prisma + Neon](#数据库集成:Prisma + Neon)
- [身份认证:NextAuth.js v5](#身份认证:NextAuth.js v5)
- [部署到 Vercel 与自托管](#部署到 Vercel 与自托管)
- [性能优化:Core Web Vitals 实战](#性能优化:Core Web Vitals 实战)
- [CI/CD 全链路工作流](#CI/CD 全链路工作流)
- [实战项目:TODO+Auth+数据库 SaaS 应用](#实战项目:TODO+Auth+数据库 SaaS 应用)
- 总结与升级路线图
1. Next.js 15 新特性一览
Next.js 15 于 2025 年底正式发布,带来了多项重要更新,以下是每个特性在企业场景中的实际价值分析。
1.1 Turbopack 正式版(Stable)
Turbopack 是基于 Rust 编写的增量打包工具,Next.js 15 将其从 Beta 升级为 Stable,并在构建性能和内存占用上做了大幅优化。
实际效果(Next.js 15 官方基准):
| 指标 | Webpack (旧) | Turbopack (新) | 提升幅度 |
|---|---|---|---|
| 本地启动时间 | ~25s | ~2s | 12x |
| 热更新速度 | ~500ms | ~50ms | 10x |
| 生产构建时间 | ~180s | ~40s | 4.5x |
| 内存占用 | ~1.2GB | ~400MB | 3x |
在 next.config.ts 中启用 Turbopack:
typescript
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 实验性功能已稳定
// 不再需要 experimental.turbopack 标志
}
export default nextConfig
开发时使用 Turbopack:
bash
# Next.js 15+ 直接使用 --turbopack 标志
next dev --turbopack
# package.json scripts
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}
⚠️ 企业注意 :Turbopack 生产构建在 Next.js 15 中仍处于稳定状态,但部分高级 Webpack 插件(如某些 SSR 插件)可能需要迁移。务必在 CI 中先用
next build测试。
1.2 Partial Prerendering(PPR)------ 渲染策略的范式转变
Partial Prerendering(PPR)允许同一个页面同时使用静态生成(SSG)和服务端渲染(SSR)。页面壳(Shell)以静态方式预渲染,动态部分以流式(Streaming)方式在运行时填充。
核心原理图:
┌─────────────────────────────────────────────┐
│ HTML 文档骨架 │ ← 静态预渲染(<1s)
│ ┌─────────────────────────────────────┐ │
│ │ 导航栏 + 静态头部(静态 HTML) │ │
│ ├─────────────────────────────────────┤ │
│ │ ██████████ Loading... ██████████ │ │ ← Suspense 边界
│ │ ██████████ Loading... ██████████ │ │
│ ├─────────────────────────────────────┤ │
│ │ 页脚(静态 HTML) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↓ 流式填充(后台并行请求)
┌─────────────────────────────────────────────┐
│ 动态区域 1: 用户个性化推荐 ✓ 已加载 │
│ 动态区域 2: 实时库存数据 ✓ 已加载 │
│ 动态区域 3: A/B 测试内容 ✓ 已加载 │
└─────────────────────────────────────────────┘
启用 PPR(Next.js 15 配置):
typescript
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
// Next.js 15: PPR 已移至 stable
ppr: true,
},
}
使用 PPR 的页面示例:
typescript
// app/products/page.tsx
import { Suspense } from 'react'
import { StaticShell } from '@/components/StaticShell'
import { DynamicRecommendations } from '@/components/DynamicRecommendations'
import { RealTimeInventory } from '@/components/RealTimeInventory'
// 启用 PPR:静态壳 + 动态流式内容
export const experimental_ppr = true
export default async function ProductsPage() {
return (
<div>
{/* 静态部分:CDN 缓存 */}
<StaticShell />
{/* 动态部分:Suspense 边界内的流式内容 */}
<Suspense fallback={<ProductSkeleton />}>
<DynamicRecommendations />
</Suspense>
<Suspense fallback={<InventorySkeleton />}>
<RealTimeInventory />
</Suspense>
</div>
)
}
PPR 的最大价值:LCP 几乎瞬间完成(静态壳先行渲染),同时保留服务端动态能力。这对电商、内容平台等既需要 SEO 又需要个性化的场景是重大利好。
1.3 Next/Image 优化升级
Next.js 15 对 next/image 做了以下关键改进:
- BlurPlaceholder 支持 SVG 格式:更小的占位符体积
- ImageResponse 支持 WebP/AVIF 优先 :配合
<picture>标签自动降级 - fetchPriority 自动推断 :基于视口位置自动设置
fetchpriority
typescript
// app/blog/[slug]/page.tsx
import Image from 'next/image'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
return (
<article>
<Image
src={`/images/posts/${slug}/hero.jpg`}
alt="博客封面"
fill
// Next.js 15: 自动根据图片位置推断优先级
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,..."
quality={85}
/>
{/* 内容... */}
</article>
)
}
1.4 其他值得注意的更新
| 特性 | 说明 | 企业价值 |
|---|---|---|
| React 19 支持 | 完整支持 React 19 新 API(use()、Actions、useOptimistic) | 表单处理更简洁 |
| next/foreign` 待 | 原生支持第三方组件库集成 | 减少 bundle 体积 |
| 缓存语义变更 | fetch()、generateStaticParams 默认行为更严格 |
减少意外缓存问题 |
| 渐进式迁移工具 | npx create-next-app@latest 提供 Pages→App Router 迁移向导 |
降低迁移成本 |
2. 项目初始化与架构设计
2.1 环境准备
bash
# 确认 Node.js 版本(Next.js 15 要求 ≥20.x)
node --version # v20.x.x 或更高
# 使用 create-next-app 创建项目
npx create-next-app@latest my-saas-app \
--typescript \
--eslint \
--app \
--src-dir \
--import-alias "@/*" \
--no-tailwind \ # 企业项目通常已有 UI 框架
--use-npm
企业提示 :使用
--src-dir将源码放在src/目录,这是企业级项目的标准做法,便于后续 monorepo 扩展。
2.2 目录结构设计
my-saas-app/
├── src/
│ ├── app/ # App Router(App Router 核心)
│ │ ├── (auth)/ # 路由组:认证相关页面(共享布局)
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ └── layout.tsx
│ │ ├── (dashboard)/ # 路由组:仪表盘(需要认证)
│ │ │ ├── layout.tsx # 仪表盘共享布局(含侧边栏)
│ │ │ ├── tasks/
│ │ │ └── settings/
│ │ ├── api/ # API Route(Server Actions 优先)
│ │ │ ├── auth/[...nextauth]/
│ │ │ └── webhooks/
│ │ ├── layout.tsx # 根布局
│ │ └── page.tsx # 首页
│ ├── components/ # 组件层
│ │ ├── ui/ # 基础 UI 组件
│ │ ├── forms/ # 表单组件
│ │ └── features/ # 业务功能组件
│ ├── lib/ # 工具库
│ │ ├── db.ts # Prisma 客户端
│ │ ├── auth.ts # NextAuth 配置
│ │ └── utils.ts
│ ├── actions/ # Server Actions
│ │ ├── tasks.ts
│ │ └── users.ts
│ ├── types/ # TypeScript 类型定义
│ └── middleware.ts # Next.js Middleware
├── prisma/
│ └── schema.prisma
├── tests/
│ ├── unit/
│ └── e2e/
├── .env.local
├── next.config.ts
├── tsconfig.json
└── package.json
2.3 TypeScript 严格模式配置
json
// tsconfig.json(企业级严格配置)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true, // 严格模式必须开启
"noUncheckedIndexedAccess": true, // 数组索引安全
"noImplicitReturns": true, // 必须处理所有返回路径
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
3. App Router vs Pages Router 深度对比
3.1 架构哲学差异
Pages Router App Router
┌─────────────────────────┐ ┌─────────────────────────┐
│ 请求 → 页面级组件渲染 │ │ 请求 → RSC → HTML │
│ (每次请求重新渲染) │ │ (服务端优先,零客户端 JS)│
├─────────────────────────┤ ├─────────────────────────┤
│ getServerSideProps │ │ async Server Components │
│ getStaticProps │ │ generateStaticParams │
│ getInitialProps │ │ generateMetadata │
│ API Routes(边缘函数) │ │ Server Actions │
│ context.req/context.res │ │ Request/Response Web API │
└─────────────────────────┘ └─────────────────────────┤
│ 流式渲染
│ 并行数据获取
│ 嵌套布局
│ 错误边界
└────────────┘
3.2 核心差异对照表
| 维度 | Pages Router | App Router |
|---|---|---|
| 默认渲染方式 | 客户端渲染(CSR) | 服务端渲染(SSR/SSG) |
| 数据获取 | getServerSideProps / getStaticProps | async 组件 + fetch |
| 布局系统 | 单一 _app.tsx + _document.tsx |
嵌套布局树(每层独立 layout.tsx) |
| 页面分组 | 无原生支持 | 路由组 (group) 共享布局 |
| 状态管理 | React hooks / Redux / Zustand | 同上,但推荐 Server Components 优先 |
| API Routes | pages/api/ 独立端点 |
Server Actions(类型安全) |
| Streaming | 需配合第三方库 | 原生 Suspense 支持 |
| 错误处理 | _error.tsx | error.tsx + global-error.tsx |
| Loading | 自定义 | loading.tsx(路由级 Loading) |
| SEO | 需 getServerSideProps | 默认 SEO 友好,generateMetadata |
| Auth 集成 | Middleware + getServerSideProps | Middleware + Server Components |
| 缓存策略 | revalidate 按路由 | 按 route segment 配置 |
3.3 数据获取代码对比
Pages Router(getServerSideProps):
typescript
// pages/dashboard/tasks.tsx
import { GetServerSidePropsContext } from 'next'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
// 数据获取与权限校验分离
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context.req, context.res, authOptions)
if (!session) {
return {
redirect: { destination: '/login', permanent: false },
}
}
const tasks = await prisma.task.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
})
return {
props: { tasks: JSON.parse(JSON.stringify(tasks)) },
}
}
// 页面组件:纯客户端组件
export default function TasksPage({ tasks }: { tasks: Task[] }) {
const [filter, setFilter] = useState('all')
return (
<div>
<TaskList tasks={tasks} onFilter={setFilter} />
</div>
)
}
App Router(Server Components):
typescript
// app/dashboard/tasks/page.tsx
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { TaskList } from '@/components/TaskList'
import { TaskFilter } from '@/components/TaskFilter'
// 认证 + 数据获取 在同一处
export default async function TasksPage() {
const session = await getServerSession(authOptions)
if (!session) {
redirect('/login')
}
// 直接查询数据库,无需 API 层
const tasks = await prisma.task.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
include: { tags: true },
})
return (
<div>
{/* 交互组件作为 Client Component 传入 */}
<TaskList initialTasks={tasks} />
</div>
)
}
3.4 企业选型建议
选择 App Router 的场景:
✅ 新建项目(无历史包袱)
✅ 需要 Server Components 减少客户端 JS
✅ SEO 是核心需求
✅ 需要 Streaming/Suspense 优化用户体验
✅ 需要最新特性(PPR、Server Actions)
继续使用 Pages Router 的场景:
⚠️ 已有大型 Pages Router 项目(迁移成本高)
⚠️ 依赖特定 Pages Router 生态插件
⚠️ 团队对 Pages Router 更熟悉(短期交付压力)
⚠️ 需要 getServerSideProps 的细粒度请求控制
混合使用(可选):
✅ App Router 作为主力
✅ Pages Router 处理遗留页面(长期渐进迁移)
4. Server Components 与 Client Components 最佳实践
4.1 核心原则
默认使用 Server Components。只有需要以下能力时才降级为 Client Components:
useState/useReduceruseEffect(生命周期)- 浏览器 API(
window、document) - 事件监听(
onClick、onChange) - 受控输入组件
- 第三方客户端库(如某些图表库)
4.2 边界划分决策树
这个组件需要:
│
├─ 用户交互(点击、输入)? → Client Component
│
├─ useEffect / 生命周期? → Client Component
│
├─ 浏览器/平台 API? → Client Component
│
├─ React hooks(除 useContext 外)? → Client Component
│
├─ 只需要渲染数据(数据库查询、文件读取)? → Server Component ✅
│
└─ 作为其他 Server Component 的子组件,且只做展示? → Server Component ✅
4.3 实践:正确的组件树结构
typescript
// ❌ 错误做法:将整个页面变成 Client Component
// app/dashboard/page.tsx
'use client' // 错误:丢失了服务端数据查询能力
import { useState } from 'react'
export default function DashboardPage() {
const [user] = useState({ name: '用户' }) // 不必要的客户端状态
const [tasks] = useState([]) // 模拟数据,而非服务端查询
// ...
}
typescript
// ✅ 正确做法:Server + Client 分层
// app/dashboard/page.tsx(Server Component - 默认)
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { DashboardHeader } from '@/components/DashboardHeader'
import { TaskBoard } from '@/components/TaskBoard' // Client Component
import { StatsCards } from '@/components/StatsCards' // Client Component
import { ActivityFeed } from '@/components/ActivityFeed' // Server Component
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
if (!session) redirect('/login')
// 服务端并行查询(性能优势)
const [tasks, stats, recentActivity] = await Promise.all([
prisma.task.findMany({ where: { userId: session.user.id } }),
prisma.task.groupBy({ by: ['status'], _count: true }),
prisma.activity.findMany({
where: { userId: session.user.id },
take: 10,
orderBy: { createdAt: 'desc' },
}),
])
return (
<div className="dashboard">
{/* Server Component: 无需交互,纯展示 */}
<DashboardHeader user={session.user} />
{/* Client Component: 有交互需求 */}
<StatsCards stats={stats} />
{/* Client Component: 需要状态管理 */}
<TaskBoard initialTasks={tasks} />
{/* Server Component: 纯展示数据 */}
<ActivityFeed activities={recentActivity} />
</div>
)
}
4.4 组件 Props 序列化规则
Server Component → Client Component:Props 必须可序列化。
typescript
// ✅ 合法的 props 类型
type ValidProps =
| string
| number
| boolean
| null
| undefined
| Date // Next.js 15 会自动序列化为 ISO 字符串
| bigint
| React.ReactElement
| SerializedObject
| SerializedArray
// ❌ 非法的 props(会触发序列化错误)
type InvalidProps =
| Function // 无法序列化
| Symbol // 无法序列化
| Promise<...> // 除非使用 `use()` 消费
Date 对象的正确处理:
typescript
// app/dashboard/page.tsx(Server Component)
export default async function DashboardPage() {
const task = await prisma.task.findFirst()
return (
// Next.js 15: Date 会自动序列化
<TaskCard
task={task}
createdAt={task.createdAt.toISOString()} // 显式转字符串更安全
/>
)
}
// components/TaskCard.tsx(Client Component)
'use client'
interface TaskCardProps {
task: Task
createdAt: string
}
export function TaskCard({ task, createdAt }: TaskCardProps) {
const date = new Date(createdAt)
return (
<div>
<span>{task.title}</span>
<span>{date.toLocaleDateString()}</span>
</div>
)
}
4.5 Context 在 App Router 中的使用
App Router 中的 Context 只能在 Client Components 中创建和使用:
typescript
// components/providers/ThemeProvider.tsx
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
typescript
// app/layout.tsx(根布局)
import { ThemeProvider } from '@/components/providers/ThemeProvider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}
5. Middleware 路由中间件
5.1 Middleware 执行流程
请求 → Middleware → 路由匹配 → Server Component → Response
↓
重定向 / 重写 / 设置 Cookie / 鉴权拦截
Next.js Middleware 运行在边缘节点(Edge Runtime),比 Server Components 更早执行,适合处理跨路由的横切关注点(Cross-Cutting Concerns)。
5.2 认证拦截实战
typescript
// src/middleware.ts(项目根目录)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
// 需要认证的路由路径
const PROTECTED_PATHS = ['/dashboard', '/settings', '/tasks']
const AUTH_PATHS = ['/login', '/register']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. 获取 JWT Token(在 Edge 环境中高效)
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})
// 2. 判断是否为受保护路径
const isProtectedPath = PROTECTED_PATHS.some(path =>
pathname.startsWith(path)
)
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path))
// 3. 未登录用户访问受保护路径 → 重定向到登录
if (isProtectedPath && !token) {
const loginUrl = new URL('/login', request.url)
// 记录原始路径,登录后跳转回来
loginUrl.searchParams.set('callbackUrl', request.url)
return NextResponse.redirect(loginUrl)
}
// 4. 已登录用户访问认证页面 → 跳转到仪表盘
if (isAuthPath && token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// 5. A/B 测试:根据 Cookie 分组
const abGroup = request.cookies.get('ab_group')?.value
if (!abGroup) {
const response = NextResponse.next()
response.cookies.set('ab_group', Math.random() > 0.5 ? 'A' : 'B', {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 天
})
return response
}
// 6. 区域重写(Geo-based routing)
const country = request.geo?.country
if (country === 'CN' && pathname === '/pricing') {
const cnUrl = request.nextUrl.clone()
cnUrl.pathname = '/pricing/cn'
return NextResponse.rewrite(cnUrl)
}
return NextResponse.next()
}
// Middleware 配置:只匹配特定路径,避免不必要的执行
export const config = {
matcher: [
/*
* 匹配所有路径,排除:
* - _next/static(静态文件)
* - _next/image(图片优化请求)
* - favicon.ico(浏览器默认请求)
* - /api/auth/*(NextAuth 自有路由)
*/
'/((?!_next/static|_next/image|favicon.ico|api/auth).*)',
],
}
5.3 Middleware 中的常见陷阱
typescript
// ❌ 错误:使用 Node.js 特定 API
import { readFile } from 'fs/promises' // 在 Edge Runtime 中不可用
// ❌ 错误:同步阻塞操作
const data = fs.readFileSync('./data.json') // 不支持
// ✅ 正确:使用 Web Standard APIs
export async function middleware(request: NextRequest) {
// URL API
const url = new URL(request.url)
// Headers API
const authHeader = request.headers.get('authorization')
// Cookies API
const token = request.cookies.get('token')
return NextResponse.next()
}
Edge Runtime 限制 :Middleware 运行在 Vercel Edge Network,使用的是受限的 Web Standard APIs 子集,不支持 Node.js 核心模块(
fs、path、crypto等原生模块)。如需使用这些能力,请使用 API Route 或 Server Actions。
6. Server Actions 表单处理
6.1 什么是 Server Actions
Server Actions 允许在 Server Components 中定义可从客户端调用的异步函数,本质上是 Next.js 自动生成的 API 端点。与传统 API Routes 相比,Server Actions 提供:
- 类型安全:参数和返回值自动推断
- 渐进增强:即使 JavaScript 未加载也能工作
- 零网络开销(同一服务器内):内部调用不产生 HTTP 请求
- 内置 CSRF 保护
6.2 创建第一个 Server Action
typescript
// src/actions/tasks.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { z } from 'zod'
// ============ 2. 定义验证 Schema ============
const CreateTaskSchema = z.object({
title: z.string().min(1, '标题不能为空').max(200),
description: z.string().max(2000).optional(),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).default('TODO'),
dueDate: z.string().datetime().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
})
// ============ 3. 失败类型定义 ============
type ActionResult =
| { success: true; data: Awaited<ReturnType<typeof createTaskInternal>> }
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
// ============ 4. 内部实现函数 ============
async function createTaskInternal(data: z.infer<typeof CreateTaskSchema>) {
const session = await getServerSession(authOptions)
if (!session) throw new Error('未授权')
return prisma.task.create({
data: {
title: data.title,
description: data.description,
status: data.status,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
priority: data.priority,
userId: session.user.id,
},
})
}
// ============ 5. 导出的 Server Action ============
export async function createTask(
_prevState: ActionResult | null,
formData: FormData
): Promise<ActionResult> {
// 表单数据验证
const rawData = {
title: formData.get('title'),
description: formData.get('description') || undefined,
status: formData.get('status') || 'TODO',
dueDate: formData.get('dueDate') || undefined,
priority: formData.get('priority') || 'MEDIUM',
}
const parsed = CreateTaskSchema.safeParse(rawData)
if (!parsed.success) {
const fieldErrors: Record<string, string[]> = {}
parsed.error.errors.forEach(err => {
const field = err.path[0] as string
if (!fieldErrors[field]) fieldErrors[field] = []
fieldErrors[field].push(err.message)
})
return { success: false, error: '验证失败', fieldErrors }
}
try {
const task = await createTaskInternal(parsed.data)
// 清除缓存,触发客户端重新渲染
revalidatePath('/dashboard/tasks')
revalidatePath('/dashboard')
return { success: true, data: task }
} catch (error) {
console.error('[createTask]', error)
return { success: false, error: '创建失败,请重试' }
}
}
// ============ 6. 更新和删除 Actions ============
export async function updateTaskStatus(
taskId: string,
status: 'TODO' | 'IN_PROGRESS' | 'DONE'
): Promise<ActionResult> {
try {
const result = await prisma.task.update({
where: { id: taskId },
data: { status },
})
revalidatePath('/dashboard/tasks')
return { success: true, data: result }
} catch {
return { success: false, error: '更新失败' }
}
}
export async function deleteTask(taskId: string): Promise<ActionResult> {
try {
await prisma.task.delete({ where: { id: taskId } })
revalidatePath('/dashboard/tasks')
return { success: true, data: null }
} catch {
return { success: false, error: '删除失败' }
}
}
6.3 在表单中使用 Server Actions
typescript
// components/forms/CreateTaskForm.tsx
'use client'
import { useActionState } from 'react'
import { createTask } from '@/actions/tasks'
import type { ActionResult } from '@/actions/tasks'
import { TASK_STATUS_OPTIONS, TASK_PRIORITY_OPTIONS } from '@/lib/constants'
// 初始状态
const initialState: ActionResult | null = null
export function CreateTaskForm() {
// useActionState: 自动管理 pending 状态和结果
const [state, formAction, isPending] = useActionState(createTask, initialState)
return (
<form action={formAction} className="space-y-4">
{/* 标题 */}
<div>
<label htmlFor="title" className="block text-sm font-medium">
任务标题 *
</label>
<input
id="title"
name="title"
type="text"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
{state?.fieldErrors?.title && (
<p className="mt-1 text-sm text-red-600">
{state.fieldErrors.title[0]}
</p>
)}
</div>
{/* 描述 */}
<div>
<label htmlFor="description" className="block text-sm font-medium">
描述
</label>
<textarea
id="description"
name="description"
rows={3}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
{/* 状态选择 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="status" className="block text-sm font-medium">状态</label>
<select
id="status"
name="status"
defaultValue="TODO"
className="mt-1 w-full rounded border px-3 py-2"
>
{TASK_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium">优先级</label>
<select
id="priority"
name="priority"
defaultValue="MEDIUM"
className="mt-1 w-full rounded border px-3 py-2"
>
{TASK_PRIORITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* 全局错误提示 */}
{state?.success === false && !state?.fieldErrors && (
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
{state.error}
</div>
)}
{/* 成功提示 */}
{state?.success && (
<div className="rounded bg-green-50 p-3 text-sm text-green-600">
任务创建成功!
</div>
)}
{/* 提交按钮 */}
<button
type="submit"
disabled={isPending}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? '创建中...' : '创建任务'}
</button>
</form>
)
}
6.4 useActionState vs useFormState
注意 :在 React 19 和 Next.js 15 中,
useFormState已被useActionState取代。两者功能相似,但useActionStateAPI 更简洁。
typescript
// React 19 / Next.js 15 推荐用法
import { useActionState } from 'react'
// 旧 API(React 18 / Next.js 14)
import { useFormState } from 'react-dom'
7. 数据库集成:Prisma + Neon
7.1 技术选型说明
| 数据库 | 特点 | 适用场景 |
|---|---|---|
| Neon | Serverless Postgres,分支,预热快,按需计费 | 快速启动,按用量付费 |
| PlanetScale | Serverless MySQL,分支,Vitess 底层 | 大流量,需要分支工作流 |
| Supabase | Postgres + 实时订阅 + Auth 内置 | 需要 BaaS 完整方案 |
| 自托管 Postgres | 完全控制,数据主权 | 合规要求,企业内网 |
本节以 Prisma ORM + Neon Serverless Postgres 为例,这是 Next.js 生态中最成熟的组合。
7.2 Prisma Schema 设计
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // Neon: 直接连接(SSR)
}
// ============ 数据模型 ============
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关系
accounts Account[]
sessions Session[]
tasks Task[]
activities Activity[]
@@index([email])
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ============ 业务模型 ============
model Task {
id String @id @default(cuid())
title String
description String? @db.Text
status TaskStatus @default(TODO)
priority TaskPriority @default(MEDIUM)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tags Tag[] @relation("TaskTags")
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum TaskPriority {
LOW
MEDIUM
HIGH
}
model Tag {
id String @id @default(cuid())
name String @unique
color String @default("#6366f1")
tasks Task[] @relation("TaskTags")
}
model Activity {
id String @id @default(cuid())
action String // "created_task", "completed_task", etc.
description String
metadata Json? // 灵活存储额外数据
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
}
7.3 Prisma Client 单例模式
关键:在 Next.js App Router 中,必须使用全局单例模式避免热更新时的连接池耗尽。
typescript
// src/lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
// 开发环境:全局缓存,避免 HMR 重置连接池
// 生产环境:天然单例
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
7.4 Neon 分支工作流
Neon 的分支功能对于企业开发流程非常有用:
bash
# .env.local 配置
DATABASE_URL="postgresql://user:password@ep-cool-xxx-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"
# Neon 直接 URL(绕过连接池,用于 SSR 高并发场景)
DIRECT_URL="postgresql://user:password@ep-cool-xxx-123456.us-east-2.aws.neon.tech/neondb?sslmode=require&connection_limit=1"
typescript
// 连接字符串超过限制时的处理
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
// 在 Server Actions 中使用连接池 URL
// 在 getStaticProps(静态生成)中使用 directUrl
8. 身份认证:NextAuth.js v5
8.1 NextAuth v5 核心变化
NextAuth v5(beta/rc 阶段,请确认稳定版发布后使用)相比 v4 有以下关键变化:
- Credentials Provider 简化 :不再强制要求
authorize回调 - 默认 Adapter:内置 Prisma Adapter,配置更简洁
- 环境变量简化 :不再需要
NEXTAUTH_URL(仅 Vercel 部署时需要) - JWT 策略增强:支持更灵活的 token 结构
8.2 认证配置
typescript
// src/auth.ts
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import Credentials from 'next-auth/providers/credentials'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import { prisma } from '@/lib/db'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import type { NextAuthConfig } from 'next-auth'
// ============ 输入验证 Schema ============
const CredentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
// ============ NextAuth v5 配置 ============
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' }, // JWT 策略(企业推荐:减少数据库查询)
pages: {
signIn: '/login',
error: '/login',
verifyRequest: '/verify-email',
},
providers: [
// OAuth 提供商
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
// 凭证登录(邮箱 + 密码)
Credentials({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
const parsed = CredentialsSchema.safeParse(credentials)
if (!parsed.success) return null
const { email, password } = parsed.data
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.passwordHash) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) return null
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
// 首次登录时添加用户 ID
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
// 将用户 ID 注入 session,供客户端和服务端使用
if (token && session.user) {
session.user.id = token.id as string
}
return session
},
},
events: {
async createUser({ user }) {
// 新用户注册时记录活动
await prisma.activity.create({
data: {
userId: user.id!,
action: 'registered',
description: '新用户注册',
},
})
},
},
})
8.3 扩展 Session 类型
typescript
// src/types/next-auth.d.ts
import 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
interface User {
id: string
}
}
8.4 API Route 处理程序
typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
8.5 登录页面实现
typescript
// src/app/(auth)/login/page.tsx
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'
const error = searchParams.get('error')
const [isLoading, setIsLoading] = useState(false)
// 邮箱密码登录
async function handleCredentialsLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
const password = formData.get('password') as string
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.ok) {
router.push(callbackUrl)
router.refresh()
} else {
setIsLoading(false)
alert('邮箱或密码错误')
}
}
// OAuth 登录
async function handleOAuthLogin(provider: 'google' | 'github') {
setIsLoading(true)
await signIn(provider, { callbackUrl })
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-6 rounded-lg bg-white p-8 shadow">
<h1 className="text-2xl font-bold">登录到 SaaS 应用</h1>
{error && (
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
登录失败,请重试
</div>
)}
{/* OAuth 登录按钮 */}
<div className="space-y-3">
<button
onClick={() => handleOAuthLogin('google')}
disabled={isLoading}
className="flex w-full items-center justify-center gap-3 rounded border px-4 py-2 hover:bg-gray-50 disabled:opacity-50"
>
<GoogleIcon />
使用 Google 登录
</button>
<button
onClick={() => handleOAuthLogin('github')}
disabled={isLoading}
className="flex w-full items-center justify-center gap-3 rounded border px-4 py-2 hover:bg-gray-50 disabled:opacity-50"
>
<GitHubIcon />
使用 GitHub 登录
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">或使用邮箱登录</span>
</div>
</div>
{/* 邮箱密码表单 */}
<form onSubmit={handleCredentialsLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium">邮箱</label>
<input
name="email"
type="email"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium">密码</label>
<input
name="password"
type="password"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
<p className="text-center text-sm text-gray-500">
还没有账号?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
注册
</Link>
</p>
</div>
</div>
)
}
9. 部署到 Vercel 与自托管
9.1 部署到 Vercel(推荐)
Vercel 是 Next.js 的原生部署平台,提供最优的集成体验:
bash
# 1. 安装 Vercel CLI
npm i -g vercel
# 2. 登录并部署
cd my-saas-app
vercel
# 3. 生产环境部署
vercel --prod
Vercel 配置文件(可选):
typescript
// vercel.json
{
"framework": "nextjs",
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"regions": ["sin1", "sfo1"], // 亚太 + 北美多区域
"functions": {
"src/app/api/**/*.ts": {
"runtime": "nodejs22.x", // 指定 Node.js 版本
"memory": 1024,
"maxDuration": 10
}
},
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}
9.2 自托管:Docker 部署
dockerfile
# Dockerfile
FROM node:22-alpine AS base
# 1. 安装依赖阶段
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
# 2. 构建阶段
FROM deps AS builder
WORKDIR /app
COPY . .
RUN npm run build
# 3. 运行阶段
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
修改 next.config.ts 以支持 standalone 输出:
typescript
// next.config.ts
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**.googleusercontent.com' },
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
],
},
}
export default nextConfig
9.3 docker-compose.yml(生产级)
yaml
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: always
ports:
- '3000:3000'
environment:
DATABASE_URL: ${DATABASE_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-saasapp}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
bash
# 启动命令
docker compose up -d --build
10. 性能优化:Core Web Vitals 实战
10.1 Core Web Vitals 目标
| 指标 | 优秀 | 需改进 | 差 |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5s--4s | > 4s |
| INP (Interaction to Next Paint) | < 200ms | 200ms--500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1--0.25 | > 0.25 |
10.2 LCP 优化:关键渲染路径
typescript
// ✅ 优化 1:预加载关键字体(app/layout.tsx)
import { Inter, Noto_Sans_SC } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // FOUT,避免字体加载时的文本隐藏
preload: true, // 预加载
})
const notoSansSC = Noto_Sans_SC({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '600', '700'],
preload: true,
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className={`${inter.className} ${notoSansSC.className}`}>
<body>{children}</body>
</html>
)
}
typescript
// ✅ 优化 2:预加载 Hero 图片(优先级最高)
import Image from 'next/image'
export function HeroSection() {
return (
<>
{/* 关键 LCP 元素:添加 priority 属性 */}
<Image
src="/hero.webp"
alt="产品主图"
width={1200}
height={600}
priority // 触发 <link rel="preload">,提升 LCP
quality={85}
/>
</>
)
}
typescript
// ✅ 优化 3:路由级 metadata(SEO + 性能)
// app/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'SaaS 应用 - 提升团队效率',
description: '现代化任务管理平台',
openGraph: {
title: 'SaaS 应用',
images: ['/og-image.jpg'],
},
}
export default function HomePage() {
return <main>...</main>
}
10.3 INP 优化:减少主线程阻塞
typescript
// ✅ 优化 4:避免大型 Client Components,降低 JS 执行时间
// 拆分为小块,按需加载
// 旧:整个仪表盘是 Client Component
// 'use client'
// export default function Dashboard() { ... } // 整个 bundle 都要下载
// 新:Server Component 包裹,小块 Client Component 按需加载
// app/dashboard/page.tsx(Server Component)
export default async function Dashboard() {
const data = await fetchDashboardData()
return (
<div>
<DashboardHeader /> {/* Server Component,无 JS */}
<StatsGrid /> {/* 小型 Client Component */}
<TaskBoard /> {/* 中型 Client Component */}
<AnalyticsChart /> {/* 可能很重 → dynamic import */}
</div>
)
}
// components/AnalyticsChart.tsx
'use client'
// 优化:大型图表库使用 dynamic import,不阻塞首屏
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(
() => import('@/components/charts/AnalyticsChart'),
{
ssr: false,
loading: () => <ChartSkeleton />,
}
)
export function AnalyticsSection() {
return <HeavyChart />
}
10.4 CLS 优化:消除布局偏移
css
/* ✅ 优化 5:图片容器固定尺寸,避免 CLS */
.image-container {
width: 100%;
max-width: 600px;
/* 方法 1: aspect-ratio(推荐) */
aspect-ratio: 16 / 9;
/* 或 */
/* 方法 2: padding-top 技巧 */
/* padding-top: 56.25%; */
position: relative;
overflow: hidden;
}
.image-container img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ✅ 优化 6:字体加载时的 CLS 防护 */
@font-face {
font-family: 'Noto Sans SC';
font-display: swap; /* FOUT vs FOIT */
size-adjust: 105%; /* 调整字体度量,减少偏移 */
}
typescript
// ✅ 优化 7:骨架屏占位,防止内容加载时的 CLS
// components/TaskCardSkeleton.tsx
export function TaskCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-5 w-3/4 rounded bg-gray-200" />
<div className="mt-2 h-3 w-1/2 rounded bg-gray-200" />
<div className="mt-3 flex items-center gap-2">
<div className="h-6 w-16 rounded-full bg-gray-200" />
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
</div>
)
}
10.5 缓存策略配置
typescript
// next.config.ts - 精细化缓存控制
const nextConfig: NextConfig = {
// 静态资源:长期缓存(内容哈希)
// 图片、字体、构建产物默认一年缓存
// 路由缓存(ISR/On-demand Revalidation)
// app/blog/[slug]/page.tsx
// export const revalidate = 3600 // 每小时重新验证
// API Routes:禁止缓存
// API Routes 默认 no-store
// 实验性:PPR 缓存
experimental: {
ppr: true,
},
}
11. CI/CD 全链路工作流
11.1 GitHub Actions 工作流
yaml
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
NEXTAUTH_SECRET: test-secret-key-min-32-chars-long
NODE_VERSION: '22'
jobs:
# ============ Job 1: 代码质量检查 ============
lint:
name: ESLint + Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Type check
run: npm run type-check
# ============ Job 2: 单元测试 ============
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
# ============ Job 3: 构建验证 ============
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Build Next.js application
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# ============ Job 4: E2E 测试 ============
e2e:
name: E2E Tests (Playwright)
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Install dependencies for test server
run: npm ci
working-directory: ./tests/e2e
- name: Build test app
run: npm run build
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Run Playwright tests
run: npx playwright test
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
# ============ Job 5: 部署到 Vercel(PR Preview) ============
deploy-preview:
name: Vercel Preview
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel (Preview)
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: ./
# ============ Job 6: 生产部署 ============
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
needs: [e2e]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://your-saas-app.com
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel (Production)
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
working-directory: ./
11.2 数据库迁移工作流
bash
# 数据库迁移脚本(在 CI 中运行)
# .github/workflows/migrate.yml
- name: Run database migrations
run: |
npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# 生产环境安全建议:
# 1. 使用 Prisma Accelerate 进行连接池管理
# 2. 使用 neon db migrate start 创建迁移预览
# 3. 在蓝绿部署中,确保新版本完全就绪后再切换流量
12. 实战项目:TODO + Auth + 数据库 SaaS 应用
12.1 项目启动脚本
bash
# 一键初始化项目
npx create-next-app@latest my-saas-app \
--typescript --eslint --app --src-dir \
--import-alias "@/*" --use-npm
cd my-saas-app
# 安装核心依赖
npm install next-auth@beta @auth/prisma-adapter \
prisma @prisma/client \
bcryptjs zod \
lucide-react class-variance-authority clsx tailwind-merge
npm install -D prisma
# 初始化 Prisma
npx prisma init
12.2 核心文件清单
1. 环境变量(.env.local):
bash
# Database
DATABASE_URL="postgresql://..."
DIRECT_URL="postgresql://..."
# NextAuth
NEXTAUTH_SECRET="your-super-secret-key-at-least-32-characters"
NEXTAUTH_URL="http://localhost:3000"
# OAuth(可选)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_ID=""
GITHUB_SECRET=""
2. Prisma Schema(prisma/schema.prisma): 见上方第 7.2 节
3. Auth 配置(src/auth.ts): 见上方第 8.2 节
4. Middleware(src/middleware.ts): 见上方第 5.2 节
5. Server Actions(src/actions/tasks.ts): 见上方第 6.1-6.4 节
6. Dashboard 页面(app/(dashboard)/page.tsx):
typescript
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/auth'
import { prisma } from '@/lib/db'
import { TaskBoard } from '@/components/features/TaskBoard'
import { StatsOverview } from '@/components/features/StatsOverview'
import { QuickActions } from '@/components/features/QuickActions'
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
if (!session) {
redirect('/login')
}
const [tasks, todayTasks, overdueTasks, completedThisWeek] = await Promise.all([
prisma.task.findMany({
where: { userId: session.user.id },
include: { tags: true },
orderBy: { createdAt: 'desc' },
}),
prisma.task.count({
where: {
userId: session.user.id,
dueDate: { gte: new Date(new Date().setHours(0, 0, 0, 0)) },
},
}),
prisma.task.count({
where: {
userId: session.user.id,
status: { not: 'DONE' },
dueDate: { lt: new Date() },
},
}),
prisma.task.count({
where: {
userId: session.user.id,
status: 'DONE',
completedAt: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
},
}),
])
const stats = {
total: tasks.length,
today: todayTasks,
overdue: overdueTasks,
completedThisWeek,
byStatus: {
TODO: tasks.filter(t => t.status === 'TODO').length,
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS').length,
DONE: tasks.filter(t => t.status === 'DONE').length,
},
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
欢迎回来,{session.user.name || '用户'}
</h1>
<p className="text-gray-500">
{new Date().toLocaleDateString('zh-CN', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
<QuickActions userId={session.user.id} />
</div>
{/* 统计卡片 */}
<StatsOverview stats={stats} />
{/* 任务看板 */}
<div className="rounded-lg bg-white p-6 shadow">
<h2 className="mb-4 text-lg font-semibold">任务总览</h2>
<TaskBoard tasks={tasks} />
</div>
</div>
</div>
)
}
7. 退出登录 Server Action:
typescript
// src/actions/auth.ts
'use server'
import { signOut } from '@/auth'
export async function logout() {
await signOut({ redirectTo: '/login' })
}
12.3 完整页面路由树
src/app/
├── (auth)/
│ ├── login/page.tsx → /login
│ └── register/page.tsx → /register
├── (dashboard)/
│ ├── layout.tsx → 仪表盘布局(含 Sidebar)
│ ├── page.tsx → /dashboard(首页)
│ ├── tasks/
│ │ ├── page.tsx → /dashboard/tasks
│ │ └── [id]/page.tsx → /dashboard/tasks/:id
│ └── settings/
│ └── page.tsx → /dashboard/settings
├── api/
│ └── auth/[...nextauth]/route.ts
└── layout.tsx → 根布局
13. 总结与升级路线图
13.1 技术栈全景
| 层级 | 技术选型 | 替代方案 |
|---|---|---|
| 框架 | Next.js 15 + React 19 | Nuxt 3, Remix |
| 路由 | App Router | Pages Router(渐进迁移) |
| 数据库 | Neon Postgres + Prisma | PlanetScale, Supabase, 自托管 |
| 认证 | NextAuth v5 | Clerk, Auth.js, Supabase Auth |
| 样式 | Tailwind CSS | CSS Modules, styled-components |
| UI 组件 | shadcn/ui | Radix UI + 自定义, Material UI |
| 部署 | Vercel | Railway, Fly.io, AWS, 自托管 |
| CI/CD | GitHub Actions | GitLab CI, Jenkins, CircleCI |
| 监控 | Vercel Analytics + Sentry | Datadog, New Relic |
13.2 从 Next.js 13/14 升级到 15 的检查清单
升级前检查:
□ 确认 Node.js ≥ 20.x
□ 运行 npm outdated 检查依赖兼容性
□ 确认所有 middleware 使用 Web Standard APIs(非 Node.js 特定 API)
□ 检查 next.config.ts 中的 experimental 配置
□ 测试 Turbopack 本地开发:`next dev --turbopack`
□ 更新 React 相关类型定义:`npm install @types/react@19 @types/react-dom@19`
升级步骤:
1. 更新 Next.js:`npm install next@15 react@19 react-dom@19`
2. 更新 TypeScript:`npm install -D typescript@5 @types/node@22`
3. 更新 eslint:`npm install -D eslint eslint-config-next@15`
4. 运行 `next dev --turbopack` 本地测试
5. 运行 `next build` 生产构建测试
6. 检查 Prisma Client:`npx prisma generate`
7. 全面回归测试(重点:Auth、Server Actions、Caching)
8. 逐步启用新特性(PPR 等)
常见问题:
❌ Turbopack 不支持:等待官方支持或回退到 Webpack
❌ Server Actions 序列化失败:检查 Props 类型
❌ Middleware 错误:确认使用 Edge-compatible 代码
❌ Prisma Client 版本:确保与 Prisma CLI 版本匹配
13.3 企业级 Next.js 项目演进路线
第一阶段(1-2 周):项目搭建
├── 初始化 Next.js 15 + TypeScript + ESLint
├── 配置 Prisma + Neon
├── 完成基础认证(NextAuth v5)
├── 部署到 Vercel
└── 完善 CI/CD 工作流
第二阶段(2-4 周):核心功能
├── 业务数据模型设计与迁移
├── Server Actions 表单处理
├── 路由权限控制(Middleware)
├── 基础性能优化(Image、Font、Loading)
└── E2E 测试覆盖
第三阶段(4-8 周):生产级优化
├── Core Web Vitals 优化(LCP < 2.5s, INP < 200ms, CLS < 0.1)
├── 引入 Partial Prerendering(PPR)
├── 监控与告警(Sentry + Vercel Analytics)
├── 多环境配置(Development/Staging/Production)
└── 数据库连接池调优
持续迭代:
├── 渐进式迁移 Pages Router → App Router
├── 引入 Feature Flags
├── 微前端架构(可选,超大型应用)
└── Edge Functions 深度应用
结语
Next.js 15 代表了 React 全栈开发的最新成熟形态:Turbopack 正式版让开发体验重回极速 ;Partial Prerendering 开创了渲染策略的新范式 ;Server Components 与 Server Actions 的组合让前后端边界前所未有地清晰且类型安全。
对于企业团队而言,这意味着更低的维护成本、更高的开发效率,以及更可预测的性能表现。建议团队在新建项目时直接采用 App Router,对现有 Pages Router 项目制定 6-12 个月的渐进迁移计划。
版权声明:本文版权所有,转载需注明出处。代码示例基于 MIT 协议开源,可自由使用。
如果你觉得这篇文章有帮助,欢迎在 CSDN 点赞、收藏。有任何问题欢迎在评论区交流!