Next.js 14 App Router 踩坑实录:5 个让我加班到凌晨的坑 🕳️

上个月把公司一个老项目从 Pages Router 迁到 App Router,本来觉得最多两天搞定,结果整整折腾了一周。中间遇到的坑,有的是文档没写清楚,有的是我自己想当然,有的纯粹是 Next.js 的行为跟直觉不一样。趁记忆还新鲜,全部记下来。

先说结论

严重程度 解决耗时 一句话总结
Server/Client Component 边界搞混 ⭐⭐⭐⭐⭐ 2天 默认是 Server Component,useState 直接炸
layout.tsx 不会重新渲染 ⭐⭐⭐⭐ 半天 切路由时 layout 状态不重置
metadata 导出和 'use client' 冲突 ⭐⭐⭐ 2小时 Client Component 不能导出 metadata
fetch 默认缓存策略 ⭐⭐⭐⭐ 1天 数据死活不更新,原来是被缓存了
动态路由 generateStaticParams 的坑 ⭐⭐⭐ 半天 build 时报错,运行时又正常

背景:为什么要迁移

项目是一个内部运营后台,之前用 Next.js 13 Pages Router 写的,功能不复杂,大概三十多个页面。迁移的直接原因是要加几个新功能,同事说「反正要改,不如一步到位上 App Router」。

说实话我一开始是拒绝的。Pages Router 用得好好的,干嘛折腾?但 Server Component 确实有吸引力------直接在组件里查数据库,不用写 API 路由了。行吧,干。

坑一:Server Component 和 Client Component 的边界

这是最大的坑。

App Router 下所有组件默认是 Server Component ,不能用 useStateuseEffectonClick 这些东西。要用就得在文件顶部加 'use client'

道理我都懂,实际写起来完全是另一回事。

第一个炸的地方

迁移一个列表页,原来的代码大概长这样:

tsx 复制代码
// app/dashboard/users/page.tsx
import { useState } from 'react'

export default function UsersPage() {
  const [search, setSearch] = useState('')
  const [users, setUsers] = useState([])

  // ... 省略 fetch 逻辑

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <UserList users={users} />
    </div>
  )
}

直接报错:

vbnet 复制代码
You're importing a component that needs useState. It only works in a Client Component 
but none of its parents are marked with "use client"

好,加 'use client'。加完之后这个页面就完全变成客户端渲染了,Server Component 的好处全没了。

正确的拆法

折腾了一天才想明白,关键是把交互逻辑拆到子组件里,页面本身保持 Server Component

tsx 复制代码
// app/dashboard/users/page.tsx(Server Component,不加 'use client')
import { prisma } from '@/lib/prisma'
import { UserSearch } from './user-search'

export default async function UsersPage() {
  // 直接在组件里查数据库,这就是 Server Component 的好处
  const users = await prisma.user.findMany({
    take: 50,
    orderBy: { createdAt: 'desc' }
  })

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">用户管理</h1>
      {/* 把需要交互的部分拆成 Client Component */}
      <UserSearch initialUsers={users} />
    </div>
  )
}
tsx 复制代码
// app/dashboard/users/user-search.tsx(Client Component)
'use client'

import { useState, useMemo } from 'react'
import type { User } from '@prisma/client'

interface Props {
  initialUsers: User[]
}

export function UserSearch({ initialUsers }: Props) {
  const [search, setSearch] = useState('')

  const filtered = useMemo(() => {
    if (!search.trim()) return initialUsers
    return initialUsers.filter(u =>
      u.name?.toLowerCase().includes(search.toLowerCase()) ||
      u.email?.toLowerCase().includes(search.toLowerCase())
    )
  }, [search, initialUsers])

  return (
    <div>
      <input
        className="border rounded px-3 py-2 mb-4 w-full max-w-md"
        placeholder="搜索用户名或邮箱..."
        value={search}
        onChange={e => setSearch(e.target.value)}
      />
      <table className="w-full">
        <thead>
          <tr>
            <th className="text-left p-2">ID</th>
            <th className="text-left p-2">姓名</th>
            <th className="text-left p-2">邮箱</th>
          </tr>
        </thead>
        <tbody>
          {filtered.map(user => (
            <tr key={user.id} className="border-t">
              <td className="p-2">{user.id}</td>
              <td className="p-2">{user.name}</td>
              <td className="p-2">{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

页面首屏服务端渲染带数据,搜索交互在客户端完成。

经验法则:能不加 'use client' 就不加,需要交互的部分拆成最小的子组件。

坑二:layout.tsx 切路由不重新渲染

这个坑隐蔽得多。

我在 layout 里放了个侧边栏,侧边栏上有「当前模块」的高亮状态,用 useState 管理。结果发现点击不同菜单,URL 变了,页面内容也变了,但侧边栏高亮不对。

原因:App Router 的 layout 在同级路由切换时不会卸载重建,状态会保留。 这是设计如此,不是 bug。文档里写了,但很容易略过。

解决方案是别用 useState 管这个状态,改用 usePathname() 直接读当前路径:

tsx 复制代码
// components/sidebar.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

const menuItems = [
  { href: '/dashboard', label: '概览' },
  { href: '/dashboard/users', label: '用户管理' },
  { href: '/dashboard/orders', label: '订单管理' },
  { href: '/dashboard/settings', label: '系统设置' },
]

export function Sidebar() {
  const pathname = usePathname()

  return (
    <nav className="w-60 bg-gray-50 min-h-screen p-4">
      {menuItems.map(item => {
        // 用 pathname 判断高亮,不依赖任何 state
        const isActive = pathname === item.href ||
          (item.href !== '/dashboard' && pathname.startsWith(item.href))

        return (
          <Link
            key={item.href}
            href={item.href}
            className={`block px-3 py-2 rounded mb-1 ${
              isActive
                ? 'bg-blue-500 text-white'
                : 'text-gray-700 hover:bg-gray-200'
            }`}
          >
            {item.label}
          </Link>
        )
      })}
    </nav>
  )
}

记住:layout 里的状态跨路由持久化,需要随路由变化的东西用 usePathnameuseSearchParams 驱动,别用 useState。

坑三:metadata 和 'use client' 不能共存

给每个页面设置 title 和 description,Next.js 14 的方式是导出 metadata 对象或 generateMetadata 函数:

tsx 复制代码
// 这样写没问题
export const metadata = {
  title: '用户管理 - 后台',
  description: '管理系统用户'
}

export default async function UsersPage() {
  // ...
}

但如果这个文件加了 'use client',metadata 导出直接被忽略------不报错,不生效,你根本不知道它没工作。

这也是坑一重要的另一个原因:页面级组件保持 Server Component,metadata 才能正常导出。 需要交互的往下拆。

如果整个页面确实必须是 Client Component(比如复杂表单页),把 metadata 放到同目录的 layout.tsx 里,或者用父级 layout 的 generateMetadata 根据路径动态生成。

坑四:fetch 默认缓存,数据死活不更新

这个坑让我怀疑了整整一天。

在 Server Component 里 fetch 了一个内部 API 拿配置数据,第一次加载正常。然后我去数据库改了数据,刷新页面------没变。清缓存刷新------还是没变。重启 dev server------变了。

原因是 Next.js 14 的 fetch 在 Server Component 里默认开启缓存 (相当于 cache: 'force-cache')。dev 模式下表现有时还不一致,更迷惑人。

tsx 复制代码
// ❌ 默认被缓存,数据不会实时更新
const res = await fetch('https://api.example.com/config')

// ✅ 方案一:每次请求都重新获取
const res = await fetch('https://api.example.com/config', {
  cache: 'no-store'
})

// ✅ 方案二:设置过期时间(ISR 的效果)
const res = await fetch('https://api.example.com/config', {
  next: { revalidate: 60 }  // 60 秒后过期
})

// ✅ 方案三:页面级别设置(影响整个页面的所有 fetch)
export const dynamic = 'force-dynamic'  // 等价于每个 fetch 都 no-store
// 或
export const revalidate = 60  // 页面级 ISR

后台系统这种数据实时性要求高的,建议直接在 layout 或 page 里设 export const dynamic = 'force-dynamic',省得一个个 fetch 去配。面向用户的前台再按需用 revalidate 做 ISR。

另外,如果用的是 Prisma 直接查数据库(不走 fetch),上面这些缓存策略不生效。Prisma 查询不经过 Next.js 的 fetch 缓存层,要控制缓存得用 unstable_cache 或者 React 的 cache 函数,又是另一个话题了。

坑五:generateStaticParams 的玄学行为

动态路由 [id] 配合 generateStaticParams 做静态生成,build 的时候遇到了诡异问题。

tsx 复制代码
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await prisma.post.findMany({
    select: { slug: true }
  })
  return posts.map(post => ({ slug: post.slug }))
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug }
  })

  if (!post) notFound()

  return <article>{post.content}</article>
}

build 报错说数据库连不上,但 next dev 跑得好好的。

排查了半天,是 build 环境的 .env 没加载到正确的数据库连接串。这不是 Next.js 的锅,但 App Router 在 build 时会真正执行 generateStaticParams 去预渲染页面,踩过 Pages Router 的 getStaticPaths 就不陌生。

还有个更隐蔽的问题:Next.js 14 中 params 在某些情况下是个 Promise。 升级到较新版本可能需要这样写:

tsx 复制代码
// 新版本需要 await params
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  // ...
}

这个变更文档里提了一句。如果从 13 直接升上来,大概率会被坑:TypeScript 会报类型错误,但没开严格模式的话,运行时可能直接拿到一个 Promise 对象当 string 用,查不到数据,返回 404,你还纳闷数据明明在数据库里。

额外收获:几个迁移小技巧

1. 渐进式迁移

App Router 和 Pages Router 可以共存。/app 下的路由优先级高于 /pages,所以可以一个页面一个页面地迁,不用一把梭。

2. loading.tsx 白送 Suspense

路由目录下放一个 loading.tsx,Next.js 自动帮你包 <Suspense>。页面里的异步数据加载期间会显示 loading 内容,不用手动写 Suspense 边界:

tsx 复制代码
// app/dashboard/users/loading.tsx
export default function Loading() {
  return (
    <div className="p-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48 mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  )
}

3. error.tsx 也是自动的

同理,放一个 error.tsx 自动充当 Error Boundary,Server Component 和 Client Component 的错误都能兜住。记得加 'use client',Error Boundary 必须是客户端组件。

小结

迁完回头看,App Router 的心智模型确实比 Pages Router 复杂,但收益是实打实的------服务端组件直接查库省掉 API 层、自动 Streaming SSR、嵌套 Layout。新项目我会直接用 App Router,老项目就看情况,别像我一样低估迁移成本。

核心就一条:想清楚每个组件是 Server 还是 Client,画好边界线,其他问题都是小问题。

迁移清单放这了,有同样计划的可以参考着来。

相关推荐
猩球中的木子2 小时前
怎么集成安装VitePlus(Vite+)并使用
前端·vite·前端工程化
李昊哲小课2 小时前
电商系统项目教程
开发语言·前端·javascript
smxgn2 小时前
spring-boot-starter和spring-boot-starter-web的关联
前端
王中阳Go2 小时前
2026年,前端这个岗位可能真的要消失了,但另一个正在崛起
前端
wing982 小时前
Vue3 接入 Google 登录:极简教程
前端·vue.js·google
weixin199701080162 小时前
货铺头商品详情页前端性能优化实战
java·前端·python
星辰_mya2 小时前
锁优化高级策略:JVM 的“灵活执法”艺术
jvm·面试
清 澜3 小时前
深度学习连续剧——手搓梯度下降法
c++·人工智能·面试·职场和发展·梯度
小道士写程序3 小时前
海洋模拟项目源码解析
javascript