前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Next.js App Router 避坑指南 10个高频错误与正确写法

首页2025-06-23 14:40:12Front-End
Next.jsApp RouterReact前端工程化SSR服务端组件

从 Pages Router 换到 App Router 的第一个月,最常见的现象是代码能跑、页面能出,但性能和预期完全对不上。服务端组件写着写着变成了一堆 'use client',明明用了 Suspense 首屏还是白等两秒,Server Action 改完数据页面纹丝不动。这些都不是 bug,是新架构下的心理惯性没转过来。

这篇把 App Router 里最容易踩的 10 个坑摆出来,每个都给出错误写法、为什么错、以及正确做法。全部基于 Next.js 官方文档的行为约定,读完你能拿走一份可以直接对着自己项目做 review 的清单。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 服务端组件到底该怎么取数,为什么不需要中间那层 API 路由
  • Route Handler 静默静态化导致数据不更新的三种破解办法
  • Suspense 放在哪一层才真的能流式上屏
  • Context Provider 怎么写才不会把整页拖成客户端渲染
  • 'use client' 的边界规则,哪些组件根本不用加
  • 服务端组件和客户端组件怎么组合,为什么 children 能穿透边界
  • Server Action 之后的数据重新验证,revalidatePath 和 revalidateTag 怎么选
  • redirect 为什么会被 try/catch 吃掉
  • Metadata API 的正确用法,以及 Next.js 15 之后 params 的变化

# 一、在服务端组件里调自己的 API 路由

这是从 Pages Router 迁过来的人第一个会犯的错。以前的思维定式是「数据要从接口拿」,于是在服务端组件里 fetch 自己项目的 /api/posts。

// 错误:在服务端组件中调用自己的 API 路由
export default async function Page() {
  // 需要多写一个 API 路由,而且地址被硬编码了
  const data = await fetch('http://localhost:3000/api/posts').then(res => res.json())

  return (
    <ul>
      {data?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

这段代码在开发环境跑得好好的,上线之后 localhost:3000 直接失效,于是又得引入一个 NEXT_PUBLIC_BASE_URL 环境变量来拼地址。绕了一大圈,问题是这一整圈本来就不该存在。

服务端组件本身就跑在服务器上,它有数据库连接、有环境变量、有内网访问权限。多调一次自己的 API 路由,等于让服务器给自己发了一个 HTTP 请求,白白付出一次完整的网络往返加一次序列化开销。

// 正确:直接在服务端组件中获取数据
export default async function Page() {
  const data = await fetch('https://api.example.com/posts', {
    // Next.js 15 起 fetch 默认不缓存,要缓存得显式声明
    next: { revalidate: 3600 }
  }).then(res => res.json())

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

这里有个细节要注意。Next.js 14 及以前 fetch 默认是缓存的,15 之后改成了默认不缓存,所以老教程里那些「不用管,框架会帮你缓存」的说法已经过期,现在必须自己写 next: { revalidate } 或 cache: 'force-cache'。这块的完整变化我在 Next.js 15 新特性完全指南 里写过。

那什么时候还需要 API 路由呢?给外部系统调用的 webhook、给移动端复用的接口、需要单独做限流鉴权的端点,这些场景 Route Handler 依然有价值。它只是不该成为自己页面取数的必经之路。

# 二、Route Handler 被静态化,数据永远不变

Next.js 默认会对 Route Handler 做静态优化。开发模式下一切正常,一部署到生产环境就出事。

// app/api/time/route.js
export async function GET() {
  console.log('API 被调用')
  // 生产环境下会被静态缓存,时间永远停在构建那一刻
  return Response.json({ time: new Date().toLocaleTimeString() })
}
@前端进阶之旅: 代码已经复制到剪贴板

部署完刷新一百次,返回的时间一模一样,日志里一条 API 被调用 都不打。第一直觉会怀疑是 CDN 缓存,去清了半天缓存也没用。

真正的原因是这个 handler 在构建期就被执行过一次,结果被固化成了静态文件。Next.js 判断的依据很简单,它看不出这个函数有任何依赖运行时信息的地方,那就默认它是个常量。

破解办法有三种,本质都是告诉框架「这个函数必须每次请求都跑」:

// 方法一:读取只有请求时才存在的信息
export async function GET(request) {
  const token = request.cookies.get('token')
  return Response.json({ time: new Date().toLocaleTimeString() })
}

// 方法二:同一文件里存在非 GET 方法
export async function GET() {
  return Response.json({ time: new Date().toLocaleTimeString() })
}

export async function POST() {
  return Response.json({ time: new Date().toLocaleTimeString() })
}

// 方法三:显式声明路由段配置
export const dynamic = 'force-dynamic'

export async function GET() {
  return Response.json({ time: new Date().toLocaleTimeString() })
}
@前端进阶之旅: 代码已经复制到剪贴板

前两种是「被动触发」,靠 cookies、headers、非 GET 方法这些运行时才能确定的东西让框架自动切换到动态模式。第三种是「主动声明」。

我的建议是能显式就显式。

export const dynamic = 'force-dynamic' 只有一行,读代码的人一眼就知道这个路由是动态的。靠 request.cookies.get('token') 这种副作用来触发,几个月后有人做重构把这行没用到的代码删了,问题又会原地复活,而且这次更难查。

# 三、客户端组件也不必绕道 API 路由

上一条的反面版本。有人以为客户端组件里只能请求自己的 /api/*,于是又造了一层转发。

'use client'

import { useState } from 'react'

export default function PostsPage() {
  const [posts, setPosts] = useState([])

  return (
    <>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={async () => {
        // 绕道自己创建的 API 路由
        const res = await fetch('/api/posts')
        const data = await res.json()
        setPosts(data)
      }}>
        获取文章
      </button>
    </>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

客户端组件运行在浏览器里,浏览器能请求任何允许跨域的地址,中间这层转发纯属多余:

'use client'

import { useState } from 'react'

export default function PostsPage() {
  const [posts, setPosts] = useState([])

  return (
    <>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={async () => {
        // 直接调外部 API
        const res = await fetch('https://api.example.com/posts')
        const data = await res.json()
        setPosts(data)
      }}>
        获取文章
      </button>
    </>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

不过这条有明确的例外,得说清楚,不然容易被带偏。需要藏 API key 的、目标接口没开 CORS 的、要在服务端做数据裁剪减少传输量的,这三种情况该加转发层就得加。判断标准不是「客户端能不能直连」,而是「直连会不会泄露东西或者根本连不上」。

# 四、Suspense 放错位置,流式渲染白写

Suspense 是 App Router 流式渲染的核心,也是最容易写成摆设的一个。

import { Suspense } from 'react'

async function fetchPosts() {
  // 假设这里有 2 秒延迟
}

// 错误:页面组件自己 await 了,Suspense 拦不住
export default async function Page() {
  const data = await fetchPosts()

  return (
    <div>
      <h1>文章列表</h1>
      <Suspense fallback={<div>加载中...</div>}>
        <PostList data={data} />
      </Suspense>
    </div>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

代码里有 Suspense,看起来该有的都有了,但用户体验和没写一样,整整两秒白屏,连 文章列表 这个标题都出不来。

那为什么加了 Suspense 还是白屏呢?因为 Suspense 能流式补上的只有它包裹的那棵子树。页面组件自己在最外层 await,整个返回值都还没产生,React 连 Suspense 的 fallback 都拿不到,自然什么都渲染不出来。

正确做法是把取数下沉到子组件里,让页面组件保持同步:

import { Suspense } from 'react'

async function Posts() {
  const data = await fetchPosts()
  return (
    <ul>
      {data.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

// 正确:页面组件不 await,异步子组件被 Suspense 包住
export default function Page() {
  return (
    <div>
      <h1>文章列表</h1>
      <Suspense fallback={<div>加载中...</div>}>
        <Posts />
      </Suspense>
    </div>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板
fe
  • 一、在服务端组件里调自己的 API 路由
  • 二、Route Handler 被静态化,数据永远不变
  • 三、客户端组件也不必绕道 API 路由
  • 四、Suspense 放错位置,流式渲染白写
  • 五、Context Provider 把整页拖下水
  • 六、'use client' 到处撒
  • 七、服务端组件塞进客户端组件
  • 八、Server Action 改完数据页面不刷新
  • 九、redirect 被 try/catch 吞掉
  • 十、Metadata 不写,SEO 全靠猜
  • 总结
  • 参考

← 手把手基于ESLint 9+Husky+Prettier+Commitlint+Lint-staged配置前端规范Next.js 15 新特性完全指南 异步API与缓存策略的全部变化 →