React: Next.js 数据获取与 API

最后更新:2026-08-26

Tom 把博客切换到 App Router 后,又遇到了新问题:不同页面的数据更新频率差异很大——文章一个月改一次,产品价格一天改几次,用户头像每次登录都可能变。他需要针对每种数据特点选择不同的获取策略,同时还要给前端提供统一的 API 接口。Next.js 的 Server Component 数据获取和 Route Handler 正好帮他解决了这些痛點。


1. 你将学到



2. 概念图解

Tom 梳理了 Next.js 中数据获取的完整流程:页面请求到达后,Next.js 根据数据获取策略决定是否缓存数据、是否重新验证、是否走 API 路由。理解这个流程帮助他针对不同数据特点选择正确的方法。

流程图中的决策节点 {缓存策略} 是本节的核心——它决定了数据获取的性能和实时性。force-cache 最快但不实时,no-store 最实时但性能最差,revalidate 和 tags 在两者之间提供了灵活的平衡方案。Route Handler 和 Middleware 作为补充机制,分别处理 API 访问和请求拦截。

100%
flowchart TD
    A[页面请求] --> B{Server Component}
    B --> C[fetch 数据]
    C --> D{缓存策略}
    D -->|force-cache| E[读取缓存<br/>默认 SSG]
    D -->|no-store| F[每次都请求<br/>实时 SSR]
    D -->|revalidate: N| G[缓存 N 秒<br/>ISR 模式]
    D -->|{next: {tags: [...]}}| H[按标签缓存<br/>On-Demand ISR]
    E --> I[返回 HTML]
    F --> I
    G --> I
    H --> I
    I --> J[Route Handler<br/>/api/*]
    J --> K[数据库/外部 API]
    I --> L{需验证?}
    L -->|是| M[Middleware<br/>鉴权/重定向]
    L -->|否| N[直接渲染]


3. 一个真实场景

Tom 在博客迁移到 App Router 后发现了数据获取的新痛点。之前用 getStaticPropsgetServerSideProps,虽然熟悉但不够灵活。比如文章列表需要每小时更新一次,但某一篇热门文章被编辑修改后需要立刻刷新——getStaticProps 做不到按页控制。

App Router 引入了全新的数据获取范式:Server Component 中直接用 fetch,通过 next.revalidatenext.tags 精确控制缓存策略。Tom 同时用 Route Handler 暴露 API 接口给评论区的前端组件调用,用 Middleware 保护仪表盘路由。下面是他为博客设计的数据获取架构。

他首先梳理了博客中所有数据源的特征:文章内容(每小时更新一次,可以接受缓存)、评论(实时更新,需要即时展示)、统计数据(每次访问都不同)、产品信息(每天更新几次,更新后需尽快展示)。然后针对每种数据类型选择了不同的获取策略。这个分析过程帮助他理解了 Next.js 数据获取设计的美妙之处——不是一刀切的"SSG 或 SSR",而是可以按页面、按请求、甚至按数据标签精确控制。

(1) Server Component 数据获取

Server Component 中可以直接 await fetch,这是 App Router 最强大的特性之一。不需要 useEffect、不需要 SWR/React Query、不需要额外的客户端状态管理库——直接在组件函数中请求数据,服务端渲染完成后连同 HTML 一起发给浏览器。

fetch 的第二个参数接受一个包含 next 配置的对象,用于控制缓存行为:{ cache: 'force-cache' } 等同于 SSG,数据只在构建时获取一次;{ cache: 'no-store' } 等同于 SSR,每次请求都重新获取;{ next: { revalidate: 60 } } 等同于 ISR,缓存 60 秒后重新验证。

更高级的用法是 On-Demand ISR:使用 next: { tags: ['posts'] } 给数据打标签,然后在 Route Handler 或管理员页面中调用 revalidateTag('posts') 主动刷新。这样文章编辑后可以立即重新生成,而无需等待 revalidate 时间到期。

三种缓存策略决策表

策略 fetch 配置 行为 适用场景
静态 SSG cache: 'force-cache' 构建时获取一次,后续全走 CDN 文章内容、关于页面
实时 SSR cache: 'no-store' 每次请求重新获取 用户数据、实时仪表盘
增量 ISR next: { revalidate: 60 } 缓存 60 秒后重新验证 产品列表、价格页面
按需刷新 next: { tags: ['x'] } 缓存 + revalidateTag() 触发刷新 CMS 内容编辑后即时更新

Tom 选择策略的原则:根据数据变动的频率和时效性要求来决定。文章内容(变动频率低)用 SSG,评论数量(变动频率高但可以接受延迟)用 ISR 每 30 秒更新,用户头像(要求即时更新)用 SSR。合理混合使用这几种策略,可以在性能和实时性之间找到最佳平衡点。

▶ 示例 1:Server Component 三种缓存策略

TSX 📖 仅展示
// app/posts/page.tsx - 策略一:force-cache(默认 SSG)
// 数据只在构建时获取一次,后续请求全走 CDN 缓存
async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts', {
    cache: 'force-cache'  // 等同于 SSG,默认行为
  }).then(r => r.json())

  return (
    <div>
      <h1>文章列表</h1>
      {posts.map((post: any) => (
        <article key={post.id} style={{ marginBottom: 16 }}>
          <h2>{post.title}</h2>
          <p>{post.body.slice(0, 100)}...</p>
        </article>
      ))}
    </div>
  )
}
export default PostsPage

// app/dashboard/stats/page.tsx - 策略二:no-store(实时 SSR)
// 每次请求都从 API 获取最新数据
export const dynamic = 'force-dynamic'

async function StatsPage() {
  const stats = await fetch('https://api.example.com/dashboard/stats', {
    cache: 'no-store'  // 每次都请求最新数据
  }).then(r => r.json())

  return (
    <div>
      <h1>实时统计</h1>
      <p>在线用户:{stats.onlineUsers}</p>
      <p>今日 PV:{stats.pageViews}</p>
      <p>API 调用次数:{stats.apiCalls}</p>
    </div>
  )
}
export default StatsPage

// app/products/page.tsx - 策略三:revalidate(ISR 增量更新)
// 缓存 60 秒,60 秒后首次访问触发重新生成
async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }  // ISR:60 秒后重新验证
  }).then(r => r.json())

  return (
    <div>
      <h1>产品列表</h1>
      {products.map((p: any) => (
        <div key={p.id} style={{ border: '1px solid #ddd', padding: 12, marginBottom: 8, borderRadius: 8 }}>
          <h3>{p.name}</h3>
          <p>价格:${p.price}</p>
          <p>库存:{p.stock > 0 ? '有货' : '缺货'}</p>
        </div>
      ))}
    </div>
  )
}
export default ProductsPage

// app/api/revalidate/route.ts - On-Demand ISR 按需刷新
// 管理员编辑文章后调用这个 API 立即刷新缓存
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const body = await request.json()
  const { tag } = body

  // 验证 secret 防止滥用
  const secret = request.headers.get('x-revalidate-secret')
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  revalidateTag(tag) // 按标签刷新缓存
  return Response.json({ revalidated: true, tag })
}
逻辑代码 61 行(超过 40 行限制,仅展示)

(2) Route Handler API 路由

Route Handler 是 App Router 中创建 API 端点的方式。在 app/api/ 目录下创建 route.ts 文件,导出一个命名函数(GETPOSTPUTDELETEPATCH),函数名对应 HTTP 方法。Route Handler 支持 Server Component 的所有特性——可以访问数据库、使用环境变量、控制缓存策略。

Tom 用 Route Handler 替代了之前博客中的 Express 后端。文章 CRUD、用户认证、评论系统都通过 Route Handler 暴露 API。结合 On-Demand ISR,编辑文章后可以立即刷新缓存,用户不需要等待 revalidate 时间。

Route Handler 也支持动态路由,在 app/api/products/[id]/route.ts 中通过 params 参数获取路径参数,适合 RESTful 风格的 API 设计。每个 Route Handler 也可以配置自己的缓存策略——例如 GET 请求可以设置 { next: { revalidate: 60 } } 实现 API 级别的缓存。

Route Handler 核心用法速查

文件路径 HTTP 方法 URL 端点 用途
app/api/posts/route.ts GET /api/posts 获取文章列表
app/api/posts/route.ts POST /api/posts 创建新文章
app/api/posts/[id]/route.ts GET /api/posts/1 获取单篇文章
app/api/posts/[id]/route.ts PUT /api/posts/1 更新文章
app/api/posts/[id]/route.ts DELETE /api/posts/1 删除文章
app/api/auth/login/route.ts POST /api/auth/login 用户登录
app/api/revalidate/route.ts POST /api/revalidate 刷新 ISR 缓存

▶ 示例 2:博客 CRUD 完整 API

TSX 📖 仅展示
// app/api/posts/route.ts - 文章列表 API(GET)和创建文章 API(POST)
import { revalidateTag } from 'next/cache'

// 模拟数据库
const posts = [
  { id: 1, title: 'Next.js 入门', body: '本文介绍 Next.js 的基础用法...', published: true },
  { id: 2, title: 'React 19 新特性', body: 'React 19 带来了哪些变化...', published: true },
]

export async function GET() {
  // 只返回已发布的文章
  const published = posts.filter(p => p.published)
  return Response.json(published)
}

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const newPost = {
      id: posts.length + 1,
      title: body.title,
      body: body.body,
      published: body.published ?? false,
      createdAt: new Date().toISOString()
    }
    posts.push(newPost)

    // 刷新文章列表的 ISR 缓存
    revalidateTag('posts')

    return Response.json(newPost, { status: 201 })
  } catch (error) {
    return Response.json({ error: 'Invalid request body' }, { status: 400 })
  }
}

// app/api/posts/[id]/route.ts - 单篇文章操作(GET / PUT / DELETE)
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const post = posts.find(p => p.id === Number(params.id))
  if (!post) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }
  return Response.json(post)
}

export async function PUT(
  request: Request,
  { params }: { params: { id: string } }
) {
  const body = await request.json()
  const index = posts.findIndex(p => p.id === Number(params.id))
  if (index === -1) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  posts[index] = { ...posts[index], ...body, id: Number(params.id) }
  revalidateTag('posts')
  return Response.json(posts[index])
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  const index = posts.findIndex(p => p.id === Number(params.id))
  if (index === -1) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  posts.splice(index, 1)
  revalidateTag('posts')
  return Response.json({ message: 'Deleted' })
}
逻辑代码 61 行(超过 40 行限制,仅展示)

(3) Middleware 中间件与请求拦截

Middleware 是 Next.js 中一个强大的请求拦截机制。它在每个请求到达页面之前执行,可以用来处理重定向、鉴权、国际化路由、A/B 测试等场景。Middleware 运行在 Edge Runtime 中,具有极低的延迟(毫秒级)。

Tom 用 Middleware 实现了三个功能:第一,未登录用户访问仪表盘时重定向到登录页;第二,根据浏览器语言自动重定向到对应的语言版本;第三,阻止爬虫访问 API 路由。

Middleware 通过 matcher 配置匹配需要拦截的路由,避免不必要的执行开销。注意 Middleware 中不能读取 req.body,也不能使用 Node.js 原生 API——它运行在 Edge 环境。

▶ 示例 3:完整的 Middleware 鉴权系统

TSX
// middleware.ts - 项目根目录,与 app/ 平级
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // === 功能1:路由守卫 ===
  // 未登录用户访问保护路由 → 重定向到登录页
  const token = request.cookies.get('session_token')?.value
  const protectedPaths = ['/dashboard', '/admin', '/profile']

  if (!token && protectedPaths.some(path => pathname.startsWith(path))) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // === 功能2:已登录用户访问登录页 → 重定向到仪表盘 ===
  if (token && pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  // === 功能3:国际化路由 ===
  // 根据 Accept-Language 头自动跳转语言版本
  const supportedLocales = ['zh', 'en', 'ja', 'pt']
  const defaultLocale = 'zh'

  // 检查 URL 是否已包含语言前缀
  const pathnameHasLocale = supportedLocales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (!pathnameHasLocale) {
    const acceptLanguage = request.headers.get('accept-language') || ''
    const preferredLocale = supportedLocales.find(locale =>
      acceptLanguage.startsWith(locale)
    ) || defaultLocale
    return NextResponse.redirect(new URL(`/${preferredLocale}${pathname}`, request.url))
  }

  // === 功能4:设置响应头 ===
  const response = NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  return response
}

// 配置 middleware 匹配路径(必填,否则所有路由都会触发)
export const config = {
  matcher: [
    // 匹配所有需要保护的路由
    '/dashboard/:path*',
    '/admin/:path*',
    '/profile/:path*',
    '/login',
    // 匹配国际化路由
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ]
}
▶ 试一试

Middleware 配置说明

配置项 说明 示例
matcher 匹配需要执行 middleware 的路由 ['/dashboard/:path*', '/login']
request.nextUrl 当前请求的 URL 对象 用于读取 pathname、searchParams
request.cookies 读取/操作 Cookie request.cookies.get('token')
NextResponse.redirect() 重定向到指定 URL 用于登录校验
NextResponse.next() 继续正常处理请求 放行合法请求
response.headers.set() 设置响应头 安全相关的响应头

Middleware 执行顺序与注意事项

Middleware 在每个匹配的请求上都会执行,因此性能至关重要。Edge Runtime 设计为微秒级执行,避免在其中做数据库查询或复杂计算。多个 Middleware 的执行顺序按 matcher 配置的顺序排列。如果某个 Middleware 返回了 redirect()rewrite(),后续的 Middleware 不会执行。

一个重要注意事项:Middleware 中的环境变量需要通过 NEXT_PUBLIC_ 前缀暴露吗?不需要——Middleware 运行在服务端环境中,可以直接访问所有环境变量。但要注意,process.env 在 Middleware 编译时就被替换为实际值,所以不能在运行时动态读取环境变量。



4. 环境变量安全策略

Tom 在使用 Route Handler 和 Middleware 时还遇到了一个关键问题:环境变量的安全访问。Next.js 中环境变量的访问规则取决于前缀——这在数据获取和 API 开发中非常重要。

环境变量访问规则

前缀 可访问位置 说明
无前缀 Server Component、Route Handler、Middleware 仅在服务端可用,不会暴露到浏览器
NEXT_PUBLIC_ 所有位置(包括浏览器) 会被编译打包到 JS 中,不能放敏感信息
NEXT_PRIVATE_ 仅在服务端可用 新增的明确标识,与无前缀行为一致

Tom 的经验法则:数据库连接字符串、API Secret Key、JWT 密钥等敏感信息——不加前缀,只在 Server Component 和 Route Handler 中使用。Google Analytics ID、Public API 地址等需要在前端使用的变量——加 NEXT_PUBLIC_ 前缀。


▶ 示例 4:ISR 增量静态再生——博客文章页

TSX
// app/blog/[slug]/page.tsx - ISR: 每 60 秒重新生成
interface BlogPost {
  title: string
  content: string
  author: string
  publishedAt: string
}

async function getPost(slug: string): Promise<BlogPost> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error('Post not found')
  return res.json()
}

export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map((post: { slug: string }) => ({ slug: post.slug }))
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  return (
    <article style={{ maxWidth: 700, margin: '0 auto', padding: 24 }}>
      <h1>{post.title}</h1>
      <p style={{ color: '#999', fontSize: 14 }}>
        By {post.author} · {new Date(post.publishedAt).toLocaleDateString()}
      </p>
      <div style={{ lineHeight: 1.8, marginTop: 16 }}>{post.content}</div>
    </article>
  )
}
▶ 试一试

▶ 示例 5:Server Actions——表单提交与数据变更

TSX
// app/actions.ts - Server Actions
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  if (!title || !content) return

  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content, author: 'Alice' }),
  })

  revalidatePath('/blog')
  redirect('/blog')
}

export async function deletePost(slug: string) {
  await fetch(`https://api.example.com/posts/${slug}`, { method: 'DELETE' })
  revalidatePath('/blog')
}

// app/blog/new/page.tsx - 新建文章表单
function NewPostPage() {
  return (
    <div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
      <h1>New Post</h1>
      <form action={createPost} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        <input name="title" placeholder="Title" required style={{ padding: 8, borderRadius: 4 }} />
        <textarea name="content" placeholder="Content..." rows={8} required style={{ padding: 8, borderRadius: 4 }} />
        <button type="submit" style={{ padding: 10, background: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
          Publish
        </button>
      </form>
    </div>
  )
}
export default NewPostPage
▶ 试一试

❓ 常见问题

Q Server Component 中 fetch 和客户端 useEffect fetch 有什么区别?
A Server Component 中的 fetch 在服务端执行,数据渲染成 HTML 后直接发给浏览器,用户看到的是完整页面,不需要 loading 状态。useEffect fetch 在浏览器端执行,用户先看到空白或 loading 状态,等 JS 下载执行完毕后才渲染内容。Server Component fetch 的首屏加载速度更快、SEO 更友好。
Q Route Handler 和传统的 Express/Koa 后端有什么区别?
A Route Handler 运行在 Next.js 服务端,不需要独立的 Node.js 服务器进程,部署更简单。但它更适合轻量级 API(BFF 模式——Backend For Frontend),复杂的后端逻辑建议还是用独立的后端服务。Route Handler 的优势在于可以和前端代码放在同一个项目中共享类型定义,部署时也是一体化部署。
Q Middleware 中能做什么不能做什么?
A Middleware 运行在 Edge Runtime(轻量级 JavaScript 环境),可以做的:读取/设置 Cookie、重定向 URL、重写 URL、设置响应头、做 A/B 测试、国际化路由。不能做的:读取 req.body、使用 Node.js 内置模块(fs、path)、访问数据库、使用 fetch 之外的外部请求。复杂的鉴权逻辑建议在 Route Handler 中完成。
Q ISR 和 On-Demand ISR 有什么区别?
A ISR 是定时重新验证——设置 revalidate: 60 后,页面最多 60 秒后更新。On-Demand ISR 是事件驱动的刷新——调用 revalidateTag()revalidatePath() 后立即更新,适合编辑内容后需要即时生效的场景。两者可以结合使用:设一个较长的 revalidate 时间作为兜底,同时通过 On-Demand ISR 在内容变化时立即刷新。
Q Route Handler 中怎么处理 CORS(跨域)?
A Route Handler 的响应中直接设置 Access-Control-Allow-Origin 等 CORS 头即可。你可以封装一个工具函数 corsHeaders() 返回统一的 CORS 响应头对象,在每个 Route Handler 中通过 Response.json(data, { headers: corsHeaders() }) 带上。如果前后端在同一域名下(Next.js 全栈部署通常如此),不需要处理 CORS。

📖 小节


📝 作业

  1. 在项目中创建 app/api/products/route.ts,实现一个模拟商品 API——GET 返回商品列表,POST 创建新商品。在 app/products/page.tsx 中用 Server Component 的 fetch 调用这个 API 展示商品列表,并设置 revalidate: 30 实现 ISR。验证 30 秒内修改商品数据后页面不会更新,30 秒后刷新页面会看到新数据。
  2. 创建 middleware.ts,保护 /dashboard/* 路由——如果 Cookie 中没有 session_token,则重定向到 /login。同时为已登录用户访问 /login 时自动跳转到 /dashboard。使用 matcher 精确配置只匹配需要的路由,避免 middleware 在 _next/static 等资源路径上执行。
  3. 实现一个 On-Demand ISR 刷新机制:在管理员页面中点击"刷新缓存"按钮,调用 POST /api/revalidate(带 x-revalidate-secret 请求头验证),通过 revalidateTag('products') 刷新商品页面的 ISR 缓存。验证刷新后页面是否立即更新,对比定时 ISR 和 On-Demand ISR 的刷新速度差异。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏