第一章:Next.js App Router——现代 Web 开发的范式

第一章:Next.js App Router——现代 Web 开发的范式

App Router 是 Next.js 13+ 引入的全新架构,它重新定义了"什么在服务端运行,什么在客户端运行"。理解这个模型,是写出高性能、可维护 SaaS 的基础。


一、Server Components vs Client Components

React 历史:
  v1-v17(2013-2021):所有组件都在客户端渲染(CSR)
  Next.js Pages Router:提供 SSR/SSG,但模型复杂
  React 18 + Next.js 13:Server Components——服务端运行的 React 组件

核心区别:
  Server Component(默认):在服务器上渲染,HTML 直接发到客户端
  Client Component(加 "use client"):在浏览器中运行,有状态和交互
// app/dashboard/page.tsx
// 默认就是 Server Component(不需要声明)

import { createClient } from '@/lib/supabase/server'
import { UserStats } from './user-stats'    // Client Component(有图表交互)
import { RecentActivity } from './activity' // Server Component(静态列表)

// 这个函数在服务器上执行!直接查询数据库,无需 API 请求
export default async function DashboardPage() {
  const supabase = createClient()
  
  // 直接在组件中查询 DB(服务端!)
  const { data: stats } = await supabase
    .from('usage_stats')
    .select('*')
    .single()
  
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      
      {/* Client Component:传入数据,客户端处理交互 */}
      <UserStats initialData={stats} />
      
      {/* Server Component:纯展示,不需要在客户端渲染 */}
      <RecentActivity />
    </div>
  )
}
// app/dashboard/user-stats.tsx
"use client"  // 声明为 Client Component

import { useState } from 'react'
import { Line } from 'recharts'  // 图表库(只在客户端有意义)

interface UserStatsProps {
  initialData: any
}

export function UserStats({ initialData }: UserStatsProps) {
  const [period, setPeriod] = useState<'7d' | '30d'>('7d')
  
  // 可以使用 useState, useEffect, onClick 等
  return (
    <div>
      <button onClick={() => setPeriod('7d')}>7 天</button>
      <button onClick={() => setPeriod('30d')}>30 天</button>
      <Line data={initialData} />
    </div>
  )
}

判断规则(记住这个 checklist):

需要 "use client" 的情况:
  ✅ 使用 useState / useReducer
  ✅ 使用 useEffect
  ✅ 使用 onClick, onChange 等事件处理器
  ✅ 使用浏览器 API(window, localStorage)
  ✅ 使用第三方需要浏览器环境的库(charts, maps)

不需要 "use client"(保持 Server Component):
  ✅ 只展示数据(无交互)
  ✅ 直接访问数据库 / 后端 API
  ✅ 访问文件系统、环境变量(服务端私密)
  ✅ SEO 重要的页面(服务端渲染,爬虫可以看到内容)

二、App Router 文件系统路由

app/
  layout.tsx              → 根布局(所有页面共享的 shell)
  page.tsx                → 首页 /
  loading.tsx             → 加载状态(自动 Suspense)
  error.tsx               → 错误边界
  not-found.tsx           → 404 页面
  
  dashboard/
    layout.tsx            → Dashboard 布局(侧边栏等)
    page.tsx              → /dashboard
    loading.tsx           → /dashboard 加载状态
    
    settings/
      page.tsx            → /dashboard/settings
      
    projects/
      page.tsx            → /dashboard/projects
      [id]/               → 动态路由
        page.tsx          → /dashboard/projects/[id]
        
  api/
    stripe/
      webhook/
        route.ts          → POST /api/stripe/webhook(API Route)
    
  (auth)/                 → 路由组(括号不出现在 URL 中)
    login/
      page.tsx            → /login
    register/
      page.tsx            → /register
    layout.tsx            → 认证页面专用布局(不带侧边栏)
// app/layout.tsx - 根布局
import { Inter } from 'next/font/google'
import { Providers } from './providers'  // 所有 Context Provider
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: {
    template: '%s | YourSaaS',  // 子页面会替换 %s
    default: 'YourSaaS - 描述',
  },
  description: '你的产品描述(SEO 重要)',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh">
      <body className={inter.className}>
        <Providers>   {/* QueryClient, ThemeProvider 等 */}
          {children}
        </Providers>
      </body>
    </html>
  )
}

三、Server Actions——表单提交的新范式

Server Actions 让你不需要写 API Route 就能从客户端调用服务端函数:

// app/actions/create-project.ts
"use server"

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
})

export async function createProject(formData: FormData) {
  // 这个函数在服务器上执行,可以直接访问数据库
  const supabase = createClient()
  
  // 验证输入(服务端验证!)
  const result = CreateProjectSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description'),
  })
  
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }
  
  // 获取当前用户
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect('/login')
  
  // 插入数据库
  const { data, error } = await supabase
    .from('projects')
    .insert({ ...result.data, user_id: user.id })
    .select()
    .single()
  
  if (error) return { error: { form: error.message } }
  
  // 刷新缓存,让页面重新获取数据
  revalidatePath('/dashboard/projects')
  redirect(`/dashboard/projects/${data.id}`)
}
// app/dashboard/projects/new/page.tsx
import { createProject } from '@/app/actions/create-project'

export default function NewProjectPage() {
  return (
    <form action={createProject}>  {/* 直接传入 Server Action */}
      <input name="name" placeholder="项目名称" required />
      <textarea name="description" placeholder="描述(可选)" />
      <button type="submit">创建项目</button>
    </form>
  )
}

四、Streaming 与 Suspense——渐进式加载 UI

// app/dashboard/page.tsx
import { Suspense } from 'react'

// 骨架屏组件
function StatsSkeleton() {
  return <div className="animate-pulse bg-gray-200 h-32 rounded-lg" />
}

// 慢加载组件(可能需要查询多个表)
async function DashboardStats() {
  // 模拟慢查询(实际是复杂的统计查询)
  const [revenue, users, churn] = await Promise.all([
    fetchRevenue(),   // 需要 1s
    fetchUserCount(), // 需要 0.5s
    fetchChurnRate(), // 需要 1.5s
  ])
  
  return (
    <div className="grid grid-cols-3 gap-4">
      <StatCard title="MRR" value={`$${revenue}`} />
      <StatCard title="活跃用户" value={users} />
      <StatCard title="流失率" value={`${churn}%`} />
    </div>
  )
}

export default function DashboardPage() {
  return (
    <div>
      {/* Suspense:DashboardStats 加载时显示骨架屏 */}
      <Suspense fallback={<StatsSkeleton />}>
        <DashboardStats />  {/* 慢加载不阻塞整个页面! */}
      </Suspense>
      
      {/* 其他内容立即渲染,不等待上面的组件 */}
      <QuickActions />
      <RecentActivity />
    </div>
  )
}

五、完整项目结构(SaaS 最佳实践)

your-saas/
├── app/
│   ├── (auth)/           → 认证相关页面(无侧边栏)
│   │   ├── login/
│   │   ├── register/
│   │   └── layout.tsx
│   ├── (dashboard)/      → 需要登录的页面
│   │   ├── dashboard/
│   │   ├── settings/
│   │   └── layout.tsx    → 带侧边栏的布局
│   ├── (marketing)/      → 公开页面(Landing Page, 定价页)
│   │   ├── page.tsx      → Landing Page
│   │   ├── pricing/
│   │   └── blog/
│   ├── api/              → API Routes
│   │   └── stripe/
│   │       └── webhook/
│   │           └── route.ts
│   ├── actions/          → Server Actions
│   └── layout.tsx        → 根布局
├── components/
│   ├── ui/               → shadcn/ui 基础组件
│   ├── auth/             → 认证相关组件
│   └── dashboard/        → Dashboard 专用组件
├── lib/
│   ├── supabase/
│   │   ├── client.ts     → 浏览器端 Supabase 客户端
│   │   └── server.ts     → 服务器端 Supabase 客户端
│   ├── stripe.ts
│   └── utils.ts
├── types/
│   └── database.ts       → Supabase 生成的类型
└── middleware.ts          → 认证中间件(保护路由)
// middleware.ts - 保护需要登录的路由
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()
  const supabase = createMiddlewareClient({ req, res })
  
  // 刷新 session(保持登录状态)
  const { data: { session } } = await supabase.auth.getSession()
  
  // 未登录时重定向到 /login
  const isAuthPage = req.nextUrl.pathname.startsWith('/login') || 
                     req.nextUrl.pathname.startsWith('/register')
  const isProtectedPage = req.nextUrl.pathname.startsWith('/dashboard')
  
  if (isProtectedPage && !session) {
    return NextResponse.redirect(new URL('/login', req.url))
  }
  
  if (isAuthPage && session) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }
  
  return res
}

export const config = {
  matcher: ['/dashboard/:path*', '/login', '/register']
}

关键认知

App Router 的三个核心思维转变

  1. "组件在哪运行"变成了第一性问题:写组件之前先问:这个组件需要在浏览器跑(有交互?有状态?)还是在服务器跑就够了?大多数展示型组件不需要 “use client”。

  2. Server Actions 消灭了"多余的 API Route":CRUD 操作不再需要写 app/api/projects/route.ts,直接写 Server Action,让代码更内聚。

  3. Suspense 让页面加载体验质的提升:不用为了快,把所有数据放进一个慢查询。用 Suspense 包裹慢组件,让快的部分立即渲染,用户体验更好。

“App Router 的出现,让一个 Next.js 开发者可以做全栈开发的 80%,而不需要维护单独的后端项目。这是 2024 年独立开发者效率最大的杠杆之一。”