Next.js 15 企业级实战:从项目搭建到CI/CD全链路(App Router + Server Components + Turbopack)

适用版本:Next.js 15.x | Node.js ≥20.x | React 19


目录

  1. [Next.js 15 新特性一览](#Next.js 15 新特性一览)
  2. 项目初始化与架构设计
  3. [App Router vs Pages Router 深度对比](#App Router vs Pages Router 深度对比)
  4. [Server Components 与 Client Components 最佳实践](#Server Components 与 Client Components 最佳实践)
  5. [Middleware 路由中间件](#Middleware 路由中间件)
  6. [Server Actions 表单处理](#Server Actions 表单处理)
  7. [数据库集成:Prisma + Neon](#数据库集成:Prisma + Neon)
  8. [身份认证:NextAuth.js v5](#身份认证:NextAuth.js v5)
  9. [部署到 Vercel 与自托管](#部署到 Vercel 与自托管)
  10. [性能优化:Core Web Vitals 实战](#性能优化:Core Web Vitals 实战)
  11. [CI/CD 全链路工作流](#CI/CD 全链路工作流)
  12. [实战项目:TODO+Auth+数据库 SaaS 应用](#实战项目:TODO+Auth+数据库 SaaS 应用)
  13. 总结与升级路线图

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 / useReducer
  • useEffect(生命周期)
  • 浏览器 API(windowdocument
  • 事件监听(onClickonChange
  • 受控输入组件
  • 第三方客户端库(如某些图表库)

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 核心模块(fspathcrypto 等原生模块)。如需使用这些能力,请使用 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 取代。两者功能相似,但 useActionState API 更简洁。

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 点赞、收藏。有任何问题欢迎在评论区交流!

相关推荐
像我这样帅的人丶你还1 小时前
🚀大文件上传的那些事
前端·javascript·架构
To_OC1 小时前
LC 39 组合总和:回溯入门必刷题,我踩过的两个坑都在这了
javascript·算法·leetcode
橘子星2 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
এ慕ོ冬℘゜3 小时前
深入 JavaScript BOM:掌握 window 对象的窗口控制魔法
开发语言·javascript·ecmascript
Highcharts3 小时前
Highcharts 矩形树图 Treemap 示列|2025年多层级出口商品结构可视化
javascript·数据可视化
大家的林语冰4 小时前
✌️ Deno 2.9 来了,ESM 直接导入 CSS,甚至能开发桌面应用?!
前端·javascript·node.js
不简说5 小时前
JS 代码技巧 vol.1 — 10 个让代码少写 30% 的小套路
前端·javascript·面试
@@@@@@@@545 小时前
JavaScript 的缺点与 TypeScript 的弥补
开发语言·javascript·typescript
Hilaku5 小时前
为什么业务型前端容易遭遇中年危机?
前端·javascript·程序员