跳到主要内容

Next.js 15 速查表:App Router、服务端组件、Server Actions、动态路由,真实例子加坑

Next.js 15 速查表,App Router、服务端组件、Server Actions、动态路由,带真实例子和坑。

  • 本地处理
  • 分类 开发运维
  • 适合 格式化、校验、压缩或检查和代码相关的文本。
143
路由 (23)

app/layout.tsx:根布局(必需)

每个 app/ 项目必须有且只有一个根 layout.tsx,负责渲染 <html> 和 <body>,包裹所有页面并在导航之间持久存在。children 是当前激活的页面或嵌套布局。

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

app/page.tsx:路由段的页面

page.tsx 让一个路由段对外可访问。app/page.tsx 是 "/",app/about/page.tsx 是 "/about"。即使有 layout,没有 page.tsx 这条路径也走不通。

// app/about/page.tsx → /about
export default function AboutPage() {
  return <h1>About us</h1>;
}

loading.tsx:流式 Suspense 兜底

同级目录放一个 loading.tsx,Next 会自动把这段路由用 <Suspense> 包起来。导航时立刻显示,页面边流边渲染。可以分段共置,做更精细的骨架屏。

// app/dashboard/loading.tsx
export default function Loading() {
  return <div className="skeleton h-40 w-full" />;
}

error.tsx:段级错误边界

error.tsx 是段级的 Client Component 错误边界,接收 error 和 reset 两个 prop。捕获 不到 根 layout 抛的错,要用 global-error.tsx。

// app/dashboard/error.tsx
'use client';
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <p>Something broke: {error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

not-found.tsx:段级 404

Server Component 里调 notFound() 会触发(或者没有任何段匹配)。放 app/not-found.tsx 是全站 404,放子目录就是该段的 404。

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
const product = await db.product.find(id);
if (!product) notFound();

// app/products/[id]/not-found.tsx
export default function NotFound() {
  return <p>Product not found.</p>;
}

template.tsx:每次重新挂载的 layout

形状和 layout.tsx 一样,但每次导航都创建新实例,state 和 effect 都重跑。需要每次切换都是新状态时用,比如埋点 page view、退场动画触发。

// app/(marketing)/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
  return <div className="fade-in">{children}</div>;
}

动态段 [slug]

文件夹名带方括号就是动态段,值通过 params 传给 page。Next 15 起 params 是 Promise,必须 await。

// app/blog/[slug]/page.tsx → /blog/hello
export default async function Post({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

通配段 [...slug]

名字前三个点匹配一个或多个段,以数组形式传入。/docs/a/b/c → params.slug = ["a","b","c"]。

// app/docs/[...slug]/page.tsx
export default async function Doc({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  return <pre>{slug.join('/')}</pre>;
}

可选通配段 [[...slug]]

两层方括号让通配段变成可选,父路径也能匹配。/docs 和 /docs/a/b 都打到同一页。访问 /docs 时 params.slug 是 undefined。

// app/docs/[[...slug]]/page.tsx
export default async function Doc({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  return <pre>{slug ? slug.join('/') : 'home'}</pre>;
}

路由组 (folder)

文件夹名加圆括号,把路由分组 但不进 URL。常用于一组互不相干的路由共享一个 layout,(marketing)/about 和 (marketing)/pricing 共用一个 layout,URL 仍是 /about /pricing。

app/
  (marketing)/
    layout.tsx         // shared marketing chrome
    about/page.tsx     → /about
    pricing/page.tsx   → /pricing
  (app)/
    layout.tsx         // shared app chrome
    dashboard/page.tsx → /dashboard

并行路由 @slot

@ 开头的文件夹是命名 slot,在同一个 layout 里并行渲染。layout 接收每个 slot 作为 prop。适合分屏视图、弹窗、仪表盘。

// app/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <>
      {children}
      {analytics}
      {team}
    </>
  );
}
// app/@analytics/page.tsx and app/@team/page.tsx fill the slots.

拦截路由 (.)folder

点开头的段在 <Link> 软导航时拦截兄弟或上级路由。经典用法:在信息流上 弹窗式 打开一张照片,直接访问 URL 时仍然是独立页面。

app/
  feed/page.tsx
  photo/[id]/page.tsx
  feed/
    (..)photo/[id]/page.tsx   // intercept /photo/[id] from /feed

page 里的 searchParams

page 收到一个 searchParams prop,装着 query string。Next 15 里它也是 Promise,要 await。读 searchParams 会让该页退出静态渲染。

// app/search/page.tsx → /search?q=foo
export default async function Search({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  return <p>Searching for {q ?? 'nothing'}</p>;
}

<Link> 实现客户端导航

next/link 做软导航,默认进入视口就预取,保留 layout state。普通链接用 href,不要 onClick + router.push。

import Link from 'next/link';
<Link href="/about" prefetch>About</Link>
<Link href="/dashboard" replace>Replace history</Link>

useRouter:客户端编程式导航

从 "next/navigation" 引入(不是 "next/router",那是 pages router 的)。Client Component 里调,可以 push / replace / refresh / back / forward。

'use client';
import { useRouter } from 'next/navigation';

export function LogoutButton() {
  const router = useRouter();
  return (
    <button onClick={() => router.replace('/login')}>
      Log out
    </button>
  );
}

Server Component 里的 redirect()

redirect() 抛出一个内部错误,框架把它转成 307。Server Component / Server Action 里调;千万别 try/catch 包它,你会把 redirect 吃掉。

import { redirect } from 'next/navigation';

export default async function Page() {
  const user = await getUser();
  if (!user) redirect('/login');
  return <Dashboard user={user} />;
}

usePathname:读当前路径(客户端)

从 "next/navigation" 引入,返回当前 pathname 字符串。最常见的用法是高亮当前导航项。每次客户端导航它都会更新,组件随之重渲染。

'use client';
import { usePathname } from 'next/navigation';

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const pathname = usePathname();
  const active = pathname === href;
  return (
    <Link href={href} aria-current={active ? 'page' : undefined}>
      {children}
    </Link>
  );
}

useSearchParams:读 query string(客户端)

返回一个只读的 URLSearchParams。因为它让组件依赖 URL,静态渲染时 Next 要求最近的父级用 <Suspense> 包住,否则整条路由会被强制动态化。

'use client';
import { useSearchParams } from 'next/navigation';

export function Filter() {
  const params = useSearchParams();
  const sort = params.get('sort') ?? 'new';
  return <span>Sorting by {sort}</span>;
}

useParams:读动态参数(客户端)

Client Component 的 hook,把当前路由的动态参数作为普通对象返回。和 page 的 params prop 不同,它不是 Promise。深层 client 组件想拿到 [id] 又不想层层透传 prop 时很方便。

'use client';
import { useParams } from 'next/navigation';

export function PostTag() {
  const { slug } = useParams<{ slug: string }>();
  return <code>{slug}</code>;
}

router.refresh:重取服务端数据但不丢状态

为当前路由向服务端重新取数据并就地合并,不做整页刷新。客户端组件的状态(输入框、滚动位置)都保留。配合 Server Action 改完数据后调,可以让列表原地更新。

'use client';
import { useRouter } from 'next/navigation';

export function RefreshButton() {
  const router = useRouter();
  return <button onClick={() => router.refresh()}>Refresh</button>;
}

permanentRedirect:308 而非 307

redirect() 发的是临时 307;permanentRedirect() 发永久 308,浏览器和爬虫会缓存。永久搬走的 URL(改了 slug、合并账号)用它,搜索引擎才会把排名转过去。

import { permanentRedirect } from 'next/navigation';

export default async function Page({ params }: { params: Promise<{ old: string }> }) {
  const { old } = await params;
  const fresh = await resolveNewSlug(old);
  if (fresh) permanentRedirect('/posts/' + fresh);
}

default.tsx:并行 slot 未匹配时的兜底

硬导航时,如果某个并行 @slot 没有匹配当前 URL 的段,Next 会渲染该 slot 的 default.tsx。没有它,直接访问该 URL 这个 slot 会 404。返回 null 表示什么都不渲染。

// app/@modal/default.tsx
export default function Default() {
  return null; // nothing in the modal slot on direct visits
}

<Link scroll={false}> 不滚动到顶

默认情况下 <Link> 和 router.push 导航后会滚到页面顶部。传 scroll={false} 保持当前滚动位置,适合标签切换、筛选变更这类应该原地发生的交互。

<Link href="/feed?tab=top" scroll={false}>Top</Link>

// or programmatically
router.push('/feed?tab=top', { scroll: false });
服务端/客户端 (12)

Server Component 是默认值

app/ 目录下每个文件默认都是服务端组件(Server Component)。服务端组件只在服务端跑,可以 async,可以读密钥、直连数据库,自身一行 JS 都不发到浏览器。

// app/posts/page.tsx — Server Component (no "use client")
import { db } from '@/lib/db';

export default async function Posts() {
  const posts = await db.post.findMany();   // direct DB access
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

"use client":切到 Client Component

在文件 最顶部 (所有 import 之前)写 "use client",把它切成客户端组件(Client Component)。需要用 hooks、浏览器 API、事件处理、有状态交互时必填。被这个文件 import 的东西都会进客户端 bundle。

'use client';
import { useState } from 'react';

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Server Component → Client Component 组合

Server Component 可以渲染 Client Component 并传可序列化的 props。反过来需要 children 模式,把 server 渲染好的内容作为 children prop 喂给 client 包装器。

// server component
import { ClientShell } from './ClientShell';
import { ServerHeavy } from './ServerHeavy';

export default function Page() {
  return (
    <ClientShell>
      <ServerHeavy />   {/* still a Server Component */}
    </ClientShell>
  );
}

Server Component 可以 async

Server Component 支持顶层 await,这就是它存在的核心意义。直接在组件体里 fetch,不用 useEffect、不用 SWR、不用 loader。

export default async function Recipes() {
  const recipes = await fetch('https://api.example.com/recipes').then((r) => r.json());
  return <RecipeList recipes={recipes} />;
}

Client Component 不能 async

把 Client Component 写成 async 会让它渲染为 Promise,React 会报警。用 useEffect + setState、useSWR、useQuery,或新的 use() hook 去消费 promise。

// WRONG
'use client';
export default async function Bad() { /* React will warn */ }

// RIGHT — use the use() primitive
'use client';
import { use } from 'react';
export default function Good({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise);
  return <pre>{JSON.stringify(data)}</pre>;
}

何时用 Server / 何时用 Client

默认 Server。只有 需要 hook、需要事件监听、需要浏览器 API(window、localStorage)、或者依赖 DOM 的三方库时,才切 Client。Client Component 尽量往叶子节点推,JS 体积才能压小。

// Page (Server) — fetches data
// └── Filters (Client) — handles input state
//     └── List (Server) — renders items
//         └── ItemActions (Client) — like / bookmark buttons

server-only:保证模块永远到不了客户端

在含密钥的模块顶部 import "server-only"。一旦有 Client Component 间接 import 了它,构建会用明确错误信息失败。给 API key、数据库凭证、内部 SDK 上一道便宜的保险。

// lib/db.ts
import 'server-only';
import { Pool } from 'pg';
export const pool = new Pool({ connectionString: process.env.DB_URL });

把 Server Action 当 prop 传给 Client Component

Server Action 是唯一能跨服务端/客户端边界传的函数。框架把它替换成一个引用,客户端通过网络调用。这样 action 定义留在服务端,而按钮活在客户端。

// server component
import { deletePost } from './actions';
import { DeleteButton } from './DeleteButton';

export default function Row({ id }: { id: string }) {
  return <DeleteButton onDelete={deletePost.bind(null, id)} />;
}

// DeleteButton.tsx
'use client';
export function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
  return <button onClick={() => onDelete()}>Delete</button>;
}

用 use() 在 Client Component 里解包 promise

Server Component 可以发起一个 fetch,把还没 await 的 promise 往下传给 Client Component,后者用 React 19 的 use() 解包。Client Component 会一直挂起直到 promise resolve,所以要用 <Suspense> 包起来。

// server
<Suspense fallback={<Skeleton />}>
  <Comments commentsPromise={getComments(postId)} />
</Suspense>

// client
'use client';
import { use } from 'react';
export function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise);
  return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>;
}

client-only:保护只在浏览器跑的模块

server-only 的镜像。在用到 window、document 或仅浏览器 SDK 的模块顶部 import "client-only"。一旦有 Server Component import 了它,构建会直接失败,而不是运行时崩。

// lib/analytics.ts
import 'client-only';

export function track(event: string) {
  window.gtag?.('event', event);
}

Context provider 必须 "use client"

React Context 依赖 hooks,所以 provider 组件必须是 Client Component。在靠近根的位置包一层,作为 children 渲染的 Server Component 照常工作,children 是透传过去的,不会在客户端重渲染。

'use client';
import { createContext } from 'react';
export const ThemeContext = createContext('light');

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
// app/layout.tsx: <ThemeProvider>{children}</ThemeProvider>

connection():显式退出静态渲染

await connection()(来自 next/server)告诉 Next 这次渲染必须等到真实请求到来,即便没读 cookies 或 headers 也强制动态渲染。比旧的 unstable_noStore 更干净地表达"这段必须每个请求都跑"。

import { connection } from 'next/server';

export default async function Page() {
  await connection();
  const rnd = Math.random(); // now safe: runs per request, not at build
  return <p>{rnd}</p>;
}
数据获取 (16)

默认缓存的 fetch()

Next 15 改了默认行为:fetch() 默认 不 缓存。要缓存得显式 { cache: "force-cache" }。同一次渲染里相同 fetch 会自动去重。

// Cache forever (until next deploy)
const data = await fetch(url, { cache: 'force-cache' }).then((r) => r.json());

revalidate:时间触发的 ISR

加 next: { revalidate: 秒数 },N 秒后后台刷新缓存。过期后第一个请求拿旧的、同时触发一次新 fetch,下一个请求拿新的。

// re-fetch at most every 60 seconds
const posts = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
}).then((r) => r.json());

cache: "no-store":永远新鲜

每次请求都要最新的数据用这个,仪表盘、账户余额、个性化内容。会把该路由标为动态。

const balance = await fetch('/api/me/balance', {
  cache: 'no-store',
}).then((r) => r.json());

next: { tags: [...] } + revalidateTag

fetch 打 tag,可以在 Server Action 或 webhook 里按需失效。多个 tag、多个 fetch,一次 revalidateTag() 全部清掉。

const posts = await fetch('/api/posts', {
  next: { tags: ['posts'] },
}).then((r) => r.json());

// elsewhere, after creating a post:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');

export const dynamic = "force-static"

强制路由静态化,即使代码里用了动态 API(cookies、headers、searchParams)。这些 API 返回空值。

// app/blog/page.tsx
export const dynamic = 'force-static';
export const revalidate = 3600;

export const dynamic = "force-dynamic"

和 force-static 相反:强制每次请求都动态渲染,即便代码看上去完全静态。当下游行为取决于你还没读的 header 时有用。

export const dynamic = 'force-dynamic';
export const revalidate = 0;

generateStaticParams:预渲染动态路径

在动态段里 export generateStaticParams,返回 params 数组,构建时预渲染这些页面。配 dynamicParams = false 让未列出的路径直接 404。

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((p) => ({ slug: p.slug }));
}
export const dynamicParams = false; // 404 anything not in the list

unstable_cache:给任意函数加缓存

给任意异步函数(数据库调用、不用 fetch 的三方 SDK)加缓存。键、revalidate、tags 的玩法和 fetch 一致。

import { unstable_cache } from 'next/cache';

export const getPostsBySlug = unstable_cache(
  async (slug: string) => db.post.findMany({ where: { slug } }),
  ['posts-by-slug'],
  { revalidate: 60, tags: ['posts'] },
);

cookies() / headers():读请求数据

next/headers 在 Server Component 和 Server Action 里读到当前请求的 cookies 和 headers。Next 15 起两者都是 async,要 await。

import { cookies, headers } from 'next/headers';

export default async function Page() {
  const c = await cookies();
  const session = c.get('session')?.value;
  const h = await headers();
  const country = h.get('x-vercel-ip-country');
  return <p>{session} · {country}</p>;
}

Promise.all 并行取数

Server Component 里一串 await 会变成瀑布。互不依赖的请求用 Promise.all 并行发出,把 TTFB 压下来。

const [user, posts, notifications] = await Promise.all([
  getUser(id),
  getPosts(id),
  getNotifications(id),
]);

export const revalidate:段级 ISR

在路由段层级 export revalidate,给该段所有缓存 fetch 和页面本身一个默认重新生成的时间窗。单个 fetch 上的 next.revalidate 仍可覆盖它。设成 0 让整段完全动态。

// app/blog/page.tsx
export const revalidate = 3600; // regenerate at most once an hour

export default async function Blog() {
  const posts = await fetch('https://api.acme.com/posts').then((r) => r.json());
  return <PostList posts={posts} />;
}

export const fetchCache:固定缓存行为

段级的逃生舱,覆盖该段内每个 fetch 的默认缓存。"force-cache" 连主动退出缓存的 fetch 也缓存;"default-no-store" 让它们全部动态。只有单个 fetch 选项不够用时才动它。

// app/dashboard/page.tsx
export const fetchCache = 'default-no-store'; // everything live by default

dynamicParams:404 还是按需渲染

带 generateStaticParams 的动态段里,dynamicParams = true(默认)会按需渲染未列出的路径并缓存它。设成 false,任何构建时没预列的路径都返回 404。

// app/shop/[id]/page.tsx
export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }];
}
export const dynamicParams = false; // /shop/3 → 404

draftMode():切换预览渲染

next/headers 的 draftMode() 读取当前请求是否处于草稿模式(由 enable() 设置的签名 cookie)。编辑用它拉未发布的 CMS 内容,其他人看缓存好的已发布版本。Next 15 里它是 async。

import { draftMode } from 'next/headers';

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { isEnabled } = await draftMode();
  const { slug } = await params;
  const post = await getPost(slug, { preview: isEnabled });
  return <Article post={post} />;
}

React cache() 去重非 fetch 读取

用 React 的 cache() 包住一个取数函数,同一请求里多个 Server Component 共享一次结果,而不是把数据库打 N 遍。作用域限于单次请求,不跨请求缓存,这点和 unstable_cache 不同。

import { cache } from 'react';
import { db } from '@/lib/db';

export const getUser = cache((id: string) =>
  db.user.findUnique({ where: { id } }),
);
// layout and page both call getUser(id) → one query

after():响应流出后再跑任务

after()(来自 next/server)安排一个回调,在响应流式发送完成后执行。适合日志、埋点、缓存预热这类不该阻塞首字节时间的工作。

import { after } from 'next/server';

export default async function Page() {
  after(async () => {
    await logPageView(); // runs after the user already has the HTML
  });
  return <Content />;
}
Server Actions (12)

"use server":声明一个 Server Action

在文件 顶部 (或异步函数 第一行 )写 "use server",把里面的 async 函数当作 RPC 端点暴露给客户端。文件级写法必须在文件最顶部;内联写法必须是函数体的第一条语句。

// app/actions.ts — file-level
'use server';

export async function createTodo(formData: FormData) {
  const title = formData.get('title') as string;
  await db.todo.create({ data: { title } });
}

<form action={serverAction}>

Server Action 通过 form 的 action prop 接入后,不依赖 JS 也能用,表单 post 到服务器,action 执行,页面刷新。客户端不需要 fetch,也不用维护 API 路由。

import { createTodo } from './actions';

export default function NewTodoForm() {
  return (
    <form action={createTodo}>
      <input name="title" required />
      <button type="submit">Add</button>
    </form>
  );
}

useActionState:处理 action 返回

React 19 的 hook,把 Server Action 包一层,返回 [state, formAction, isPending]。最适合内联校验报错,且 没 JS 也能用。

'use client';
import { useActionState } from 'react';
import { createTodo } from './actions';

export function Form() {
  const [state, action, pending] = useActionState(createTodo, { error: null });
  return (
    <form action={action}>
      <input name="title" />
      {state.error && <p className="text-red-600">{state.error}</p>}
      <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>
    </form>
  );
}

useFormStatus:读取提交状态

React 19 hook,读父级 <form> 的 pending / data / method / action。必须在表单 内部 的子组件里调,不能在表单自身的组件里,否则 pending 永远是 false。

'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Submitting...' : 'Submit'}</button>;
}

// In the parent form:
// <form action={...}><SubmitButton /></form>

action 里的 revalidatePath / revalidateTag

改完数据后,在 action 里调 revalidatePath("/todos") 或 revalidateTag("todos"),下一次渲染就拿到新数据。不调的话 UI 会一直是旧的,直到下次完整导航。

'use server';
import { revalidatePath } from 'next/cache';

export async function deleteTodo(id: string) {
  await db.todo.delete({ where: { id } });
  revalidatePath('/todos');
}

从 Client Component 调 Server Action

import 进来当普通 async 函数调(button onClick、自定义 hook,随你)。网络请求、序列化、错误上报都由框架管。

'use client';
import { deleteTodo } from '@/app/actions';

export function DeleteButton({ id }: { id: string }) {
  return <button onClick={() => deleteTodo(id)}>Delete</button>;
}

用 Zod 校验 action 入参

所有从浏览器来的东西都不可信。用 zod(或其他)校验 FormData,无效就提前 return,数据库调用 永远 不能拿到生数据。

'use server';
import { z } from 'zod';
const Schema = z.object({ title: z.string().min(1).max(120) });

export async function createTodo(formData: FormData) {
  const parsed = Schema.safeParse({ title: formData.get('title') });
  if (!parsed.success) return { error: 'Invalid title' };
  await db.todo.create({ data: parsed.data });
}

用 bind() 给 action 传额外参数

form 的 action 只收到 FormData。想再传一个 id 或上下文,就 bind:action.bind(null, id)。绑定的参数排在 FormData 参数前面,在服务端安全传递,不会暴露在 DOM 里。

'use server';
export async function updateTodo(id: string, formData: FormData) {
  await db.todo.update({ where: { id }, data: { title: formData.get('title') as string } });
}

// in the form
<form action={updateTodo.bind(null, todo.id)}>
  <input name="title" defaultValue={todo.title} />
</form>

用隐藏 input 把数据带进 action

bind 之外的另一种做法:表单里放一个隐藏 input,从 FormData 里读。简单、没 JS 也能用。但隐藏字段里的东西客户端能改,所以服务端仍要校验。

'use server';
export async function vote(formData: FormData) {
  const id = formData.get('postId') as string;
  await db.post.update({ where: { id }, data: { votes: { increment: 1 } } });
}

// form
<form action={vote}>
  <input type="hidden" name="postId" value={post.id} />
  <button>Upvote</button>
</form>

用 useOptimistic 做乐观更新

React 19 的 useOptimistic 在 Server Action 执行期间立刻显示预期结果,真实数据到达后再对齐(出错就回滚)。点赞、待办、聊天发送都很适合。

'use client';
import { useOptimistic } from 'react';

export function Likes({ count, like }: { count: number; like: () => Promise<void> }) {
  const [optimistic, addOptimistic] = useOptimistic(count, (c) => c + 1);
  return (
    <form action={async () => { addOptimistic(null); await like(); }}>
      <button>♥ {optimistic}</button>
    </form>
  );
}

从 useActionState 返回带类型的错误

给 action 一个带类型的 state 结构,表单就能渲染字段级错误,且没 JS 也保留。action 返回新 state,useActionState 在下次渲染时把它接回表单。

'use server';
type State = { errors?: { email?: string }; ok?: boolean };
export async function subscribe(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email'));
  if (!email.includes('@')) return { errors: { email: 'Enter a valid email' } };
  await db.subscriber.create({ data: { email } });
  return { ok: true };
}

Server Action 仅 POST 且自带 CSRF 防护

每个 Server Action 都是 POST 请求。Next 会用 Origin 对比 Host header,挡掉跨站调用,基础 CSRF 算是处理了。但鉴权授权还得你在 action 内部自己做,框架不知道谁有权限。

'use server';
import { auth } from '@/lib/auth';

export async function deleteAccount(id: string) {
  const session = await auth();
  if (session?.user.id !== id) throw new Error('Forbidden');
  await db.user.delete({ where: { id } });
}
流式渲染 (5)

用 Suspense 边界做流式渲染

把慢的 Server Component 包进 <Suspense>,先把外壳流出去,慢的那块再异步换上。用户立刻看到能用的 UI,而不是干瞪眼。

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <SlowList />
      </Suspense>
    </div>
  );
}

多个并行 Suspense 边界

每个边界独立流入,最慢的那块 不会 阻塞别的。每个独立数据源单独包一层,把并行度拉满。

<>
  <Suspense fallback={<Sk1 />}><Feed /></Suspense>
  <Suspense fallback={<Sk2 />}><Sidebar /></Suspense>
  <Suspense fallback={<Sk3 />}><Comments /></Suspense>
</>

loading.tsx 就是 Suspense 边界

和 page 同级的 loading.tsx 就 是 这段路由的 Suspense fallback。等价于自己用 <Suspense> 包 <Page />。

app/dashboard/
  page.tsx        // async, slow
  loading.tsx     // shown until page resolves

preload 模式提前发起 fetch

在 layout 或 page 顶部调一个 void preload(id),在真正需要它的组件渲染前就启动缓存 fetch。配合 React cache(),后面的 await 几乎瞬时,因为请求早就发出去了。

import { cache } from 'react';
const getItem = cache((id: string) => fetch('/api/item/' + id).then((r) => r.json()));
export const preload = (id: string) => { void getItem(id); };

export default function Layout({ children }: { children: React.ReactNode }) {
  preload('42'); // warm the cache while children render
  return <>{children}</>;
}

给 useSearchParams 包 Suspense

调用 useSearchParams 的 Client Component 必须在 <Suspense> 边界下,否则整页的静态预渲染会让构建失败(报 "missing suspense boundary")。只把读 query 的那部分包起来即可。

import { Suspense } from 'react';
import { SearchBox } from './SearchBox'; // uses useSearchParams

export default function Page() {
  return (
    <Suspense fallback={null}>
      <SearchBox />
    </Suspense>
  );
}
Metadata (9)

静态 metadata 导出

在任何 layout.tsx 或 page.tsx 里 export 一个名为 metadata 的常量,Next 会自动渲染 <head> 标签,title、description、OpenGraph、robots、viewport。

// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About — Acme',
  description: 'Acme makes calm tooling.',
  openGraph: { images: ['/og/about.png'] },
};

generateMetadata:动态生成

metadata 依赖 params 或拉到的数据时,export async generateMetadata 函数。里面的 fetch 会和 page 自己的 fetch 自动去重。

import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post.title, description: post.excerpt };
}

Title template:子页标题不重复

根 layout 里 metadata.title 写 { template: "%s · Acme", default: "Acme" }。子页只写字符串 title,会自动拼成 "随便 · Acme"。

// app/layout.tsx
export const metadata = {
  title: { template: '%s · Acme', default: 'Acme' },
};

// app/about/page.tsx
export const metadata = { title: 'About' };  // → "About · Acme"

opengraph-image.tsx:动态 OG 图

段目录下放一个 opengraph-image.tsx,用 ImageResponse + JSX 动态生成 OG 图。Twitter card 对应 twitter-image.tsx。

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export default async function OG() {
  return new ImageResponse(
    <div style={{ fontSize: 96, background: '#000', color: '#fff' }}>Hello</div>,
    size,
  );
}

sitemap.ts 和 robots.ts

在 app/sitemap.ts(或 sitemap.xml/route.ts)export 一个函数,返回 sitemap 条目。app/robots.ts 同理。两者都自动缓存 + 自动重新生成。

// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany();
  return posts.map((p) => ({
    url: 'https://acme.com/blog/' + p.slug,
    lastModified: p.updatedAt,
  }));
}

metadataBase:解析相对 OG URL

在根 layout 设置 metadataBase,openGraph/twitter 里的相对图片路径才会变成绝对 URL。不设,Next 会警告,社交爬虫可能加载不到卡片图,因为它们需要绝对地址。

// app/layout.tsx
export const metadata = {
  metadataBase: new URL('https://acme.com'),
  openGraph: { images: ['/og.png'] }, // → https://acme.com/og.png
};

alternates.canonical:设置规范链接

声明 metadata.alternates.canonical 来输出 <link rel="canonical">。同一内容能从多个 URL(带/不带 query)访问时尤其关键,搜索引擎会把排名信号汇总到一个上。

export const metadata = {
  alternates: {
    canonical: 'https://acme.com/blog/hello',
    languages: { 'en-US': '/en/blog/hello', 'zh-CN': '/zh/blog/hello' },
  },
};

viewport 导出:从 metadata 拆出来

现在的 Next 把 viewport 和 themeColor 从 metadata 拆到独立的 viewport 导出(一个 Viewport 对象或 generateViewport)。继续放在 metadata 里仍能用,但会打废弃警告。

import type { Viewport } from 'next';

export const viewport: Viewport = {
  themeColor: '#0b0b0f',
  width: 'device-width',
  initialScale: 1,
};

metadata 里的 icons 和 manifest

可以通过 metadata.icons / metadata.manifest 声明 favicon、apple-touch-icon 和 PWA manifest。或者干脆不配,把 icon.png / apple-icon.png / manifest.ts 放进 app/,Next 自动接好标签。

export const metadata = {
  icons: { icon: '/favicon.ico', apple: '/apple-icon.png' },
  manifest: '/manifest.webmanifest',
};
// or convention: app/icon.png, app/apple-icon.png, app/manifest.ts
next/image (9)

next/image:基础用法

next/image 自动出 AVIF / WebP、生成响应式 srcset、视口外懒加载。非 fill 模式必须给 width + height,浏览器才能 提前 占位,避免 CLS。

import Image from 'next/image';

<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

placeholder="blur":模糊占位图

placeholder="blur" + 远程图用 blurDataURL / 本地 import 自动生成。加载顺滑,不闪白框。

import Image from 'next/image';
import hero from './hero.jpg';   // auto blurDataURL

<Image src={hero} alt="Hero" placeholder="blur" />

priority:优先加载首屏

给 LCP 图加 priority,关闭懒加载并加 fetchpriority="high" 提示。每页 只 用一次,放在主视觉图上。

<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

fill:填满已定位的父元素

不知道图尺寸时用 fill。父元素 必须 position: relative(或 absolute/fixed)并且有确定尺寸,否则图会塌成 0。

<div style={{ position: 'relative', width: '100%', height: 400 }}>
  <Image src="/banner.jpg" alt="" fill style={{ objectFit: 'cover' }} />
</div>

images.remotePatterns 配置

next/image 拒绝优化未允许的远程域名。在 next.config 的 images.remotePatterns 里加进去,防止你不小心把整个公网拿来当图床。

// next.config.ts
export default {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.acme.com', pathname: '/img/**' },
    ],
  },
};

sizes:告诉浏览器图片实际显示多大

响应式(fill 或 width:100%)图片要设 sizes,浏览器才会从 srcset 里挑对的那张,而不是下最大的。sizes 写错或漏写,是图片下载过大的头号原因。

<Image
  src="/cover.jpg"
  alt=""
  fill
  sizes="(max-width: 768px) 100vw, 50vw"
  style={{ objectFit: 'cover' }}
/>

quality 属性:体积换清晰度

next/image 默认 quality 75。缩略图调低省体积;主视觉图压缩痕迹明显时调高。现在的 Next 里,非默认值要先在 next.config 的 images.qualities 里允许。

// next.config.ts
export default { images: { qualities: [50, 75, 90] } };

// usage
<Image src="/thumb.jpg" alt="" width={120} height={120} quality={50} />

unoptimized:跳过优化器

给某张图(或在 next.config 里全局)设 unoptimized,原样返回文件不动它。静态导出且没有图片服务器、不想被重编码的动图 GIF、或 SVG 都需要它。

<Image src="/loader.gif" alt="" width={48} height={48} unoptimized />

// or globally
// next.config.ts → { images: { unoptimized: true } }

自定义 loader 接外部图片 CDN

用一个 loader 函数构造转换 URL,把 next/image 指向 Cloudinary、imgix 或自家 CDN。loader 把 width/quality 拼进参数,让 CDN 来做缩放,而不是 Next 的优化器。

'use client';
const cloudinary = ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
  `https://res.cloudinary.com/acme/image/upload/w_${width},q_${quality ?? 'auto'}/${src}`;

<Image loader={cloudinary} src="hero.jpg" alt="" width={800} height={400} />
next/font (4)

next/font/google:自托管 Google 字体

构建时下载字体并自托管 .woff2,运行时不访问 fonts.googleapis.com,省一次 DNS,无 FOUT。只打包你声明的字符集。

import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font/local:自托管本地字体

同样自动优化,但用于你自己的字体。文件路径相对当前 import 文件给。

import localFont from 'next/font/local';
const display = localFont({
  src: './fonts/Display-Regular.woff2',
  display: 'swap',
  variable: '--font-display',
});

字体 CSS 变量 + Tailwind

给字体 loader 传 variable,把它暴露成 CSS 自定义属性,而不是 className。把这个变量 class 挂到 <html>,再在 Tailwind 的 fontFamily 配置里引用 var(--font-sans)。

import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });

export default function Layout({ children }: { children: React.ReactNode }) {
  return <html className={inter.variable}><body>{children}</body></html>;
}
// tailwind.config: fontFamily: { sans: ['var(--font-sans)'] }

多字重与 adjustFontFallback 默认值

可变字体不用列 weight;静态字体只传你真正用到的字重,bundle 才小。next/font 还会自动算出一个尺寸对齐的系统兜底字体,在 web 字体加载时减少布局抖动。

import { Roboto } from 'next/font/google';
const roboto = Roboto({
  subsets: ['latin'],
  weight: ['400', '700'], // only what you use
  display: 'swap',
});
中间件 (6)

middleware.ts:请求中间件

放项目根目录(和 app/ 同级,不在 app/ 里)。Edge 上、页面渲染 之前 跑。可以 rewrite、redirect、加 header、做路由门禁。 不 能用 Node API、 不 能做长任务,微秒级。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const session = req.cookies.get('session');
  if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  return NextResponse.next();
}

middleware matcher:限定生效范围

export config.matcher 限定中间件跑的路径。静态资源显式排除,给每个 PNG 跑一次中间件太亏了。

export const config = {
  matcher: [
    /*
     * Match all paths except:
     *   - _next/static / _next/image
     *   - favicon.ico
     *   - any file with an extension (image, font, etc.)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
  ],
};

Rewrite + 写入 request header

NextResponse.rewrite 把匹配的路径换成另一条内部路径,URL 栏 不 变。还能 clone header、加额外字段,下游 Server Component 通过 headers() 读到。

const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-experiment', 'B');
return NextResponse.rewrite(new URL('/new-home', req.url), {
  request: { headers: requestHeaders },
});

在 middleware 里读写 cookie

从 req.cookies 读入站 cookie,用 res.cookies.set 往响应上写出站 cookie。常用于轮换 session token,或盖一个首访 A/B 分桶,供下游页面读取。

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  if (!req.cookies.get('bucket')) {
    res.cookies.set('bucket', Math.random() < 0.5 ? 'A' : 'B', { path: '/' });
  }
  return res;
}

middleware 里的地理位置和 IP

在 Vercel 这类平台上,地理数据以请求 header 形式到达(x-vercel-ip-country、x-vercel-ip-city)。在 middleware 里读它们,可在页面渲染前重定向到对应语言区,或限制区域内容。

export function middleware(req: NextRequest) {
  const country = req.headers.get('x-vercel-ip-country');
  if (country === 'DE' && req.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL('/de', req.url));
  }
  return NextResponse.next();
}

middleware 的 nodejs runtime(实验)

middleware 默认跑 Edge runtime。较新版本的 Next 允许通过实验性的 nodeMiddleware 开关,让 middleware 切到 Node.js runtime,解锁 Node API,代价是冷启动更慢。

// next.config.ts
export default { experimental: { nodeMiddleware: true } };

// middleware.ts
export const config = { runtime: 'nodejs' };
Route handler (8)

route.ts:App Router 的 API 端点

在 没 page.tsx 的段下放 route.ts。export 命名的 HTTP 方法(GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS),每个接收 Request,返回 Response。

// app/api/ping/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ ok: true, ts: Date.now() });
}

export async function POST(req: Request) {
  const body = await req.json();
  return NextResponse.json({ echo: body });
}

route.ts 里的动态 params

动态段下的 route.ts 第二参数收 params,Promise 形状和 page 一样,用前先 await。

// app/api/users/[id]/route.ts
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const user = await db.user.findUnique({ where: { id } });
  return Response.json(user);
}

给 route handler 加缓存

GET handler 只有在 不 用动态 API 时才默认静态。要明确控制,用段级 config:export const dynamic = "force-static" 或 revalidate = N。

// app/api/posts/route.ts
export const dynamic = 'force-static';
export const revalidate = 60;

export async function GET() {
  const posts = await db.post.findMany();
  return Response.json(posts);
}

route handler 流式返回

返回包了 ReadableStream 的 Response,实现流式 token / SSE / 分块输出。LLM 场景必备,不能让用户对着转圈圈干等 15 秒。

export async function GET() {
  const stream = new ReadableStream({
    async start(controller) {
      for (const chunk of ['hello', ' ', 'world']) {
        controller.enqueue(new TextEncoder().encode(chunk));
        await new Promise((r) => setTimeout(r, 200));
      }
      controller.close();
    },
  });
  return new Response(stream, { headers: { 'content-type': 'text/plain' } });
}

在 route handler 里读 query 参数

route handler 拿到的是标准 Request。用 request.url 构造一个 URL,通过 searchParams 读 query。读 searchParams 会把 handler 标为动态,于是它每个请求都跑,而不是被缓存。

// app/api/search/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get('q') ?? '';
  const results = await db.item.findMany({ where: { name: { contains: q } } });
  return Response.json(results);
}

route handler 里的 CORS header

App Router 不会自动处理 CORS。要自己设 header,并在别的源的浏览器要调你的端点时,显式处理 OPTIONS 预检。

const cors = {
  'Access-Control-Allow-Origin': 'https://app.acme.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
};
export async function OPTIONS() {
  return new Response(null, { status: 204, headers: cors });
}
export async function GET() {
  return Response.json({ ok: true }, { headers: cors });
}

从 route handler 设置 cookie

在 route handler 里用 next/headers 的 cookies()(Next 15 里 async)往响应上设 cookie,很适合签发 session 的登录端点。设置 cookie 会让 handler 退出静态缓存。

// app/api/login/route.ts
import { cookies } from 'next/headers';

export async function POST(req: Request) {
  const { token } = await req.json();
  (await cookies()).set('session', token, { httpOnly: true, secure: true, path: '/' });
  return Response.json({ ok: true });
}

返回自定义状态码与重定向

错误时返回带显式 status 的 new Response,或用 NextResponse.redirect 发 3xx。校验失败时,422 配一个字段错误的 JSON body,比干巴巴的 400 对客户端更友好。

import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const body = await req.json();
  if (!body.email) {
    return NextResponse.json({ errors: { email: 'required' } }, { status: 422 });
  }
  return NextResponse.redirect(new URL('/welcome', req.url), 303);
}
环境变量 (4)

NEXT_PUBLIC_ vs 仅服务端 env

只有 NEXT_PUBLIC_ 开头的变量会被打进客户端 bundle,其余都只在服务端可见。 绝对不要 把密钥加 NEXT_PUBLIC_ 前缀,一旦发布就永远公开了。

# .env.local
DATABASE_URL=postgres://...           # server-only
NEXT_PUBLIC_GA_ID=G-XXXXXXX           # client-visible

// usage
const db = process.env.DATABASE_URL;          // OK on server only
const ga = process.env.NEXT_PUBLIC_GA_ID;     // OK anywhere

.env 加载顺序

.env.local 永远胜过 .env.development.local > .env.development > .env。.env.local 默认 gitignore,真实密钥 只 放这里。

# load order (highest priority first)
.env.local
.env.[mode].local       # mode = development | production | test
.env.[mode]
.env

启动时用 schema 校验 env

缺失的环境变量应该在启动时大声报错,而不是在请求深处悄悄变成 undefined。在单独的 env.ts 里用 zod 解析 process.env,然后到处 import 它,而不是直接碰 process.env。

// env.ts
import { z } from 'zod';
export const env = z
  .object({
    DATABASE_URL: z.string().url(),
    NEXT_PUBLIC_GA_ID: z.string().min(1),
  })
  .parse(process.env);

next.config 里的 env:内联非前缀变量

next.config 里的 env 键在构建时把指定的服务端变量内联进客户端 bundle,即便没有 NEXT_PUBLIC_ 前缀。要省着用,绝不放密钥,因为值最终还是会公开。

// next.config.ts
export default {
  env: { BUILD_ID: process.env.GIT_SHA ?? 'dev' },
};
// client code: process.env.BUILD_ID is replaced at build time
部署 (9)

Vercel:零配置部署

代码推 GitHub,Vercel 导入,完事。Vercel 自动识别 Next,用自家优化过的构建管线,PR 自动出预览 URL,middleware 自动跑 Edge,图片走 CDN。

# zero config needed
vercel        # one-off deploy from CLI
vercel --prod # production deploy
# or just connect the GitHub repo in dashboard

output: "standalone":自托管友好

next.config 写 output: "standalone",生成最小的 .next/standalone/ 目录:server、依赖、node server.js。塞进 Docker 镜像比 copy node_modules 小 10 倍左右。

// next.config.ts
export default { output: 'standalone' };

// Dockerfile (excerpt)
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Edge runtime:按路由切换

export const runtime = "edge" 让某个 page 或 route handler 跑在基于 V8 isolate 的 Edge runtime,而不是 Node。冷启动快,但 不能 用 Node API(fs 用不了,某些平台 Buffer 也用不了)。

// app/api/geo/route.ts
export const runtime = 'edge';

export async function GET(req: Request) {
  return Response.json({ country: req.headers.get('x-vercel-ip-country') });
}

next build:它到底做了什么

跑 TS 检查(除非 ignoreBuildErrors)、跑 ESLint(除非 ignoreDuringBuilds)、生成路由清单、把 SSG 路由静态渲染、用 Turbopack / webpack 打客户端 bundle,最后打印路由表。

pnpm next build
# Route (app)                              Size     First Load JS
# ┌ ○ /                                    1.2 kB        82 kB
# ├ ○ /about                               0.5 kB        81 kB
# └ λ /dashboard                           5.0 kB        90 kB
# ○ (Static)  prerendered as static content
# λ (Dynamic) server-rendered on demand

next start:跑生产服务器

next build 之后,next start 启动优化过的 Node 服务器,提供预构建产物。它不会感知源码改动,那是 next dev 的活。自托管者先 build 再 start;PM2 或容器负责保活。

pnpm next build
pnpm next start -p 3000
# behind a process manager
pm2 start "pnpm next start" --name web

basePath:把应用挂到子路径下

应用挂在 /app 而不是域名根目录时设 basePath。Next 会自动给路由、<Link>、资源加前缀。href 照常不带前缀写,只有硬编码的字符串 URL 需要手动处理。

// next.config.ts
export default { basePath: '/app' };
// /about now serves at /app/about; <Link href="/about" /> still works

用 Cache-Control 缓存静态资源

_next/static 下的文件带内容 hash,可以放心 immutable 缓存一年。自己的 public/ 文件或 route handler,通过 headers() 配置或 Response header 设 Cache-Control 来控制 CDN 行为。

// next.config.ts
export default {
  async headers() {
    return [{
      source: '/fonts/:path*',
      headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
    }];
  },
};

next.config 里的 redirects 和 rewrites

对于静态、配置时就确定的路由规则,用 redirects() 和 rewrites() 这两个 async 函数,而不是 middleware。redirect 改 URL 栏;rewrite 在内部代理同时保留 URL。它们在 middleware 之前执行。

// next.config.ts
export default {
  async redirects() {
    return [{ source: '/old', destination: '/new', permanent: true }];
  },
  async rewrites() {
    return [{ source: '/api/v1/:path*', destination: 'https://api.acme.com/:path*' }];
  },
};

instrumentation 钩子:服务器启动时注册

在项目根的 instrumentation.ts 里 export 一个 register() 函数,服务器进程启动时跑一次,用来接 OpenTelemetry、Sentry 或指标导出器。它在 Node 和 Edge runtime 里都会运行。

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./otel'); // start tracing in the Node runtime only
  }
}
常见坑 (26)

坑:Client Component 不能 async

"use client" 组件写成 async 会渲染出一个 Promise,React 19 运行时会告警。要么保持同步、把 fetch 抬到 Server Component,要么用 use() / useSWR / useQuery 消费 promise。

// WRONG
'use client';
export default async function Bad() { /* renders a Promise */ }

// RIGHT — pass a promise prop and consume it with use()
'use client';
import { use } from 'react';
export default function Good({ p }: { p: Promise<X> }) {
  const x = use(p);
  return <pre>{JSON.stringify(x)}</pre>;
}

坑:Server Action 要走 form 或 import

不能用 fetch("/...some-server-action") 直接调。要么把 action 传给 form 的 action prop,要么 import 函数后在 Client Component 里调。RPC 通道由框架接好。

// from a Client Component
'use client';
import { createTodo } from '@/app/actions';
<button onClick={() => createTodo(formData)}>Add</button>

// or from a form
<form action={createTodo}><input name="title" /></form>

坑:hook 在 Server Component 里挂

useState、useEffect、useRef 等在没 "use client" 的文件里直接抛错。加上 directive,或者把交互那块拆成单独的 client 子组件。

// WRONG — server component cannot use hooks
export default function Bad() {
  const [n, setN] = useState(0); // throws
}

// RIGHT
'use client';
import { useState } from 'react';
export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

坑:hydration mismatch

服务端和客户端首屏 HTML 必须一致。在渲染里用 Date.now()、Math.random()、读 window 或 localStorage 都会触发不匹配。挪到 useEffect 里,或者用 mounted 标志门控,suppressHydrationWarning 谨慎用。

'use client';
import { useEffect, useState } from 'react';
export function Clock() {
  const [now, setNow] = useState<string>(''); // empty on first render
  useEffect(() => {
    setNow(new Date().toLocaleTimeString());
  }, []);
  return <time>{now}</time>;
}

坑:"use server" 必须在文件顶部

文件级 directive 必须是 第一行 非注释、非空白内容。放到 import 下面就 静默 失效,action 退化成普通服务端函数,客户端调不到。

// WRONG — directive below import is ignored
import { z } from 'zod';
'use server';   // silently does nothing

// RIGHT
'use server';
import { z } from 'zod';

坑:"use client" 切掉下游一切

一旦给文件加 "use client",它(间接) import 的所有模块都会进客户端 bundle。把 directive 推到最小的叶子,通常就是单个 Button 或 Toggle。

// app/page.tsx (Server) — keep this server
import { Counter } from './Counter';     // Client leaf
export default function Page() {
  return <><HeavyServerStuff /><Counter /></>;
}

// app/Counter.tsx — minimal client surface
'use client';
import { useState } from 'react';
export function Counter() { /* ... */ }

坑:Next 15 起 params / searchParams 是 Promise

Next 15 起每个页面收到的 params、searchParams 都是 Promise,必须 await。直接同步读 dev 会警告、生产构建会挂。cookies() 和 headers() 同理。

// WRONG (Next 14 style)
export default function Page({ params }: { params: { id: string } }) {
  return <p>{params.id}</p>;
}

// RIGHT (Next 15)
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <p>{id}</p>;
}

坑:Next 15 起 fetch 默认 不 缓存

15 之前 fetch 默认 force-cache,15 起改成 no-store。升级后突然每个请求都打你的 API,就显式 { cache: "force-cache" } 或 { next: { revalidate: N } } 加回去。

// Next 14: cached by default
await fetch(url);
// Next 15: NOT cached by default. To cache:
await fetch(url, { cache: 'force-cache' });
await fetch(url, { next: { revalidate: 60 } });

坑:useFormStatus 读父级表单

useFormStatus 只在 form 内部 子组件里有值。和 <form> 写在同一个组件里就永远 pending=false。一定要抽出 <SubmitButton /> 子组件。

'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
  const { pending } = useFormStatus();    // sees the parent <form>
  return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
// parent:
// <form action={action}><SubmitButton /></form>

坑:middleware 不能用 Node API

middleware 跑在 Edge runtime,没有 fs、path、crypto.createHash,任何 Node 专属 API 都不能用。改用 Web Crypto、fetch、URL、Request、Response。真要用 Node 就把工作挪到 route handler。

// WRONG
import fs from 'node:fs';   // build fails

// RIGHT — Web Crypto in middleware
const hash = await crypto.subtle.digest(
  'SHA-256',
  new TextEncoder().encode('hello'),
);

坑:NEXT_PUBLIC_ 变量在构建时烘进去

运行时在服务器上设 NEXT_PUBLIC_FOO 对客户端 bundle 没用,值在构建时就字面量替换了。要换值就重新构建。要运行时配置就走 route handler 暴露。

// next build runs with NEXT_PUBLIC_API=https://prod.acme.com
// process.env.NEXT_PUBLIC_API is REPLACED at build:
console.log(process.env.NEXT_PUBLIC_API); // "https://prod.acme.com"
// changing env at runtime does NOT change the client bundle.

坑:跨服务端/客户端边界传函数

Server Component 给 Client Component 只能传可序列化的 props(字符串、数字、普通对象、JSX、Server Action)。 不能 传普通函数或类实例,不可序列化,构建会报错。

// WRONG — passing a function from Server → Client
<ClientChild onClick={() => doStuff()} />   // build error

// RIGHT — pass a Server Action (special-cased)
import { doStuff } from './actions';
<ClientChild action={doStuff} />

坑:全局 CSS 只能在根 layout

globals.css 只能 在 app/layout.tsx(根) import。在别的组件 import 会让构建失败。组件级样式用 CSS Modules 或 Tailwind。

// app/layout.tsx — OK
import './globals.css';

// app/feed/page.tsx — build error
import '../globals.css';   // ❌

坑:revalidatePath 不立刻清缓存,只是排进队列

revalidatePath 只是把路径标记为失效。 下次 请求才会拿到新数据,action 本身不会等。如果你紧接着 redirect,在 Vercel 上用户会落到新页面 + 新数据;本地可能还要把目标路径也 revalidatePath 一下。

'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function action() {
  await mutate();
  revalidatePath('/todos');   // mark /todos stale
  redirect('/todos');         // next render serves fresh data
}

坑:缓存会"固化"数据库查询计划

没有动态 API + 全是缓存 fetch 的页面会在构建时静态渲染。HTML 反映的是构建时的数据,不是请求时的。任何个性化数据都要 { cache: "no-store" },否则你会把某个用户的数据发给所有人。

// WRONG — personalized data accidentally static
const me = await fetch('/api/me').then((r) => r.json()); // cached!
// Every visitor sees the first build user's data.

// RIGHT
const me = await fetch('/api/me', { cache: 'no-store' }).then((r) => r.json());

坑:Server Component 在下游每次导航都重渲染

Server Component 在每次命中其子路由的请求都会服务端渲染,包括嵌套 layout。 不 缓存就会每次都付出代价。用 fetch 缓存、unstable_cache、或 React.cache 来 memo。

import { cache } from 'react';
export const getUser = cache(async (id: string) =>
  db.user.findUnique({ where: { id } }),
);

// safe to call from any number of Server Components — dedupes within one request

坑:useSearchParams 没包 Suspense 会让构建失败

静态生成时 Next 不知道 query string,所以没包 <Suspense> 的 useSearchParams 会在构建时抛 "missing suspense boundary"。只包读它的那个组件,别包整页。

// WRONG — build error
export default function Page() { return <UsesSearchParams />; }

// RIGHT
import { Suspense } from 'react';
export default function Page() {
  return <Suspense fallback={null}><UsesSearchParams /></Suspense>;
}

坑:在 App Router 里 import next/router

next/router 和它导出的 useRouter 属于 Pages Router。在 app/ 里用会抛 "NextRouter was not mounted"。导航 hook 一律从 next/navigation 引入。

// WRONG
import { useRouter } from 'next/router'; // Pages Router only

// RIGHT (App Router)
import { useRouter, usePathname, useSearchParams } from 'next/navigation';

坑:try/catch 吞掉 redirect() 和 notFound()

redirect() 和 notFound() 靠抛一个框架会捕获的特殊错误来工作。一个宽泛的 try/catch 把它们包住,就吃掉了这个控制流信号,redirect 于是悄无声息地不发生。把它们放在 try 外面,或者重新抛出。

// WRONG
try {
  const u = await getUser();
  if (!u) redirect('/login'); // caught and swallowed below
} catch (e) { console.error(e); }

// RIGHT — call after the try, or rethrow framework errors
const u = await getUser();
if (!u) redirect('/login');

坑:Server Action 返回值必须可序列化

Server Action 返回的东西会发回客户端,所以必须可序列化:普通对象、字符串、数字、日期、数组。返回类实例、函数或数据库游标会失败。先映射成普通结构。

// WRONG — returns a Prisma model instance with methods
export async function getTodo(id: string) { return db.todo.findUnique({ where: { id } }); }

// RIGHT — return a plain object
export async function getTodo(id: string) {
  const t = await db.todo.findUnique({ where: { id } });
  return t ? { id: t.id, title: t.title, done: t.done } : null;
}

坑:cookies()/headers() 让路由变动态

读 cookies()、headers()、draftMode() 或 searchParams 会让整条路由退出静态渲染,变成每个请求服务端渲染。如果本想要静态页却没了,通常是组件树深处不小心读了一次 cookies()。

// this single line forces the whole route dynamic
import { cookies } from 'next/headers';
const theme = (await cookies()).get('theme')?.value;
// if you only need it client-side, read document.cookie in a Client Component instead

坑:共享模块里写环境相关代码

被 Server 和 Client Component 同时 import 的模块,顶层不能引用 window(服务端会崩),也不能引用 process.env 里的密钥(会泄露到客户端)。把环境相关的部分用 server-only / client-only 隔开。

// WRONG — top-level window in a shared util
export const origin = window.location.origin; // crashes on the server

// RIGHT — defer to call time, guard the environment
export const getOrigin = () =>
  typeof window === 'undefined' ? process.env.SITE_URL! : window.location.origin;

坑:串行 await 制造请求瀑布

一个接一个的独立 await 是串行执行的,总耗时是加和而不是取最大。如果请求 B 不需要 A 的结果,用 Promise.all 同时发。只有真的有数据依赖时才串起来。

// WRONG — waterfall, ~2x slower
const user = await getUser(id);
const posts = await getPosts(id); // does not depend on user

// RIGHT — parallel
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);

坑:日期和数字格式化导致 hydration 漂移

toLocaleString 和 Intl 按运行时的 locale 和时区格式化。服务器(常是 UTC)和用户浏览器可能不一致,产生 hydration 不匹配。在客户端的 effect 里格式化,或显式传 locale 和 timeZone。

// RIGHT — pin locale + timeZone so server and client agree
const fmt = new Intl.DateTimeFormat('en-US', {
  timeZone: 'UTC',
  dateStyle: 'medium',
});
return <time>{fmt.format(new Date(post.createdAt))}</time>;

坑:桶文件 import 拖大客户端 bundle

从一个巨大的 index 桶文件里 import 单个图标(import { Icon } from "lib")可能把整个包拖进客户端 bundle。直接 import 深层路径,或用 optimizePackageImports 让 Next 对桶文件做 tree-shake。

// next.config.ts
export default {
  experimental: { optimizePackageImports: ['lucide-react', '@mui/icons-material'] },
};
// now: import { Camera } from 'lucide-react' only ships Camera

坑:在 Server Component 渲染里改数据

Server Component 的渲染可能跑多次(还会被缓存),所以必须无副作用。在渲染里写数据库、发邮件、发 POST 都是 bug,这些应放进由用户操作触发的 Server Action 或 route handler。

// WRONG — write during render, runs on every (cached) render
export default async function Page() {
  await db.view.create({ data: { at: new Date() } }); // side effect!
  return <Article />;
}
// RIGHT — log after the response with after(), or in a route handler

这个工具能做什么

围绕 App Router 和 Next.js 15 实际生产里能用的写法做的可 搜索速查表。八十多条目,每条都有可直接拷贝的代码、双语 EN/ZH 的简短说明,以及(真要踩的时候)那个真实团队 最常栽进去的坑。App Router 基础(layout.tsx、page.tsx、 loading.tsx、error.tsx、not-found.tsx、template.tsx);各 种路由形态(动态 [slug]、通配 [...slug]、可选通配 [[...slug]]、路由组 (folder)、并行 @slot、拦截 (.)folder);服务端 / 客户端组件边界,以及把 "use client" 边界尽量往叶子推、压小 JS bundle 的取舍。数据获取按 Next 15 新默认讲(fetch 默认不再缓存,需要显式 force-cache / revalidate / tags 切回去),还有 generateStaticParams、unstable_cache、cookies() / headers() 现在是 Promise、Promise.all 做并行取数。 Server Actions 端到端:"use server" 文件级 / 内联两种 写法,form 的 action prop,useActionState、useFormStatus, revalidatePath / revalidateTag,Zod 校验入参。 Suspense 流式渲染。Metadata API,含 generateMetadata、 title template、opengraph-image.tsx、sitemap.ts、 robots.ts。next/image(priority、placeholder blur、 fill、remotePatterns)和 next/font(google + 本地都 自托管)。Edge 中间件 + matcher 写法。App Router 的 route handler(route.ts)、runtime 配置、流式返回。 环境变量(NEXT_PUBLIC_ vs 仅服务端、.env 加载顺序、 构建时烘进去 vs 运行时)。部署路径(Vercel 零配置、 Docker 用 output standalone、按路由切 Edge runtime)。 再加上专门的「坑」一节:Client Component 不能 async、 Server Action 必须走 form 或 import、hydration mismatch、"use server" / "use client" 必须在文件顶部、 Next 15 起 params / searchParams 是 Promise、fetch 默 认改了、useFormStatus 在父级 form 的坑、middleware 不能用 Node API、NEXT_PUBLIC_ 构建时烘进去、跨边界传 函数报错、全局 CSS 只能在根 layout、revalidatePath 其实是排队失效、个性化页面被意外静态化。按类别筛, 一次性搜标题 / 说明 / 代码,每条一键复制。全部在浏 览器里跑,不上传不追踪。

工具细节

输入
文件 + 文本
页面会根据工具类型展示文本框、数值控件、文件选择或结构化输入。
输出
即时结果 + 复制 + 预览
结果区优先给出可操作结果,支持项会显示复制、下载或可视化预览。
隐私
可能使用网络查询
组件源码里检测到网络调用,页面会按工具逻辑处理;敏感内容建议先脱敏。
保存 / 分享
本地保存偏好
偏好、历史或草稿保存在本机浏览器,不需要账号。
性能预算
首屏 JS ≤ 30 KB
没有声明 WASM 依赖,适合快速打开和移动端使用。
适用场景
开发运维 · 程序员
分类和职业标签用于推荐相关工具、组织内链,并帮助用户快速判断是否适合当前任务。

怎么用

  1. 1. 输入

    把内容粘贴或拖入工具面板。

  2. 2. 处理

    点击按钮,在浏览器内本地处理,文件不上传。

  3. 3. 复制 / 下载

    一键复制结果或下载到本地。

Next.js 速查表 适合怎么用

适合穿插在写代码、查问题、做 Review、上线前的小任务里。

适合开发场景

  • 格式化、校验、压缩或检查和代码相关的文本。
  • 把片段整理好再放进文档、工单、提交或交接材料。
  • 不切换工具,快速检查一个小 payload。

开发检查项

  • 压缩、混淆这类不可逆处理,先对副本操作。
  • 除非确认工具本地处理,不要粘贴密钥和敏感片段。
  • 转换后的代码上线前,仍要跑自己的测试或 lint。

下一步可以接着做

这些入口会把当前任务接到更完整的工具链里。

  1. 1 JSON 格式化与校验 浏览器内即时格式化、校验、压缩 JSON,数据不离开本地。 打开
  2. 2 React Hooks 速查表 React Hooks 速查表,17 个内置 hook (useState / useEffect / useMemo / useTransition / useFormStatus...) 含真实例子和常见坑。 打开
  3. 3 Vue 3 速查表 Vue 3 速查表,Composition API、响应式、组件、指令、Pinia,带 Options API 对照。 打开

真实使用场景

  • 把 Next 14 升到 15,别把数据库打挂

    你升级到 Next 15 一上线,十分钟内 Postgres 连接数翻了 四倍。元凶就是 fetch 默认行为翻转:原来悄悄走 force-cache 的每个 fetch() 现在都成了 no-store。打开 「数据获取」标签页,在仓库里搜所有打自己 API 的 fetch( 调用,给那些本该静态的加上 { cache: "force-cache" } 或 { next: { revalidate: 60 } }。 标注五分钟搞定,不用回滚。

  • 写一个没 JS 也能用的删评论按钮

    组里新人写了个 Server Action,却用 fetch("/api/...") 去 调,结果 404。「Server Actions」标签页给出两种正确接 法:把 action import 进 Client Component,或者交给 form 的 action prop。你选 form 这条,按钮在 hydration 之前就能点;再在子组件里用 useFormStatus 加个 pending 转圈,提交后 revalidatePath("/post/[id]"),评论立刻 消失。

  • 演示前十分钟干掉 hydration mismatch 警告

    离客户演示还有十五分钟,控制台狂报 hydration failed, 一个时间戳还在闪。你翻开 hydration 那条 FAQ,发现自己 把 new Date() 直接写进了 JSX,于是套用第三种修法:把 时钟挪进一个 Client Component,挂载前返回 null,挂载 后用 useEffect 翻 mounted 标志再渲染。一个文件八行, 警告没了,也不用满树乱撒 suppressHydrationWarning。

  • 带一个只会 pages router 的外包上手

    来了个外包,一直伸手去摸 getServerSideProps 和 pages/api。与其开 40 分钟会,不如把 App Router 几节 甩过去:layout.tsx 对 _app、route handler 对 pages/api、 Server Component 对 getStaticProps。他搜一下「params」 马上明白现在 params 是个要 await 的 Promise,省下他 本来要为一个看不懂的 TypeScript 报错耗掉的整个下午。

常见踩坑

  • 把 "use server" 写在 import 下面,它会静默失效;directive 要放在文件第一行非注释内容,所有 import 之前。

  • 在拥有 form 的同一个组件里调 useFormStatus,它恒返回 pending false;要在 form 下面的子组件里读它。

  • Next 15 里忘了 await params,params 和 searchParams 现在是 Promise,得写 const { slug } = await params,不是 const { slug } = params。

隐私说明

这份速查是一个纯静态页。你输入的搜索词和任何内容都只留在浏览器 里,对内存中的代码段数组做过滤,不走网络、不进 URL、也不执行任何 代码。公司代理后面或气隙机器上都能照常用。

常见问题

类似工具组合

做你这行的人, 还会一起用这些。

Made by Toolora · 100% client-side · Updated 2026-06-13