错误处理

错误分两类:

  • 可预期错误:表单校验失败、请求失败 — 把它们当作「返回值」,不要 throw
  • 未捕获异常:真正的 bug — 抛出去,由错误边界兜底。

处理可预期错误

Server Function

校验失败、请求非 2xx,应该返回错误对象:

'use server';
export async function createPost(prevState: any, formData: FormData) {
  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: JSON.stringify({
      title: formData.get('title'),
      content: formData.get('content'),
    }),
  });
  if (!res.ok) return { message: '创建失败' };
  return { message: '' };
}

客户端用 useActionState 读取:

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, { message: '' });
  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>提交</button>
    </form>
  );
}

在服务端组件中处理

直接根据响应决定渲染分支或重定向:

export default async function Page() {
  const res = await fetch('https://…');
  if (!res.ok) return <p>出错了</p>;
  const data = await res.json();
  return <Posts data={data} />;
}

404:notFound

在路由段里调用 notFound() 就会渲染最近的 not-found.tsx

import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/posts';

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound();
  return <div>{post.title}</div>;
}

配套的 app/blog/not-found.tsx

export default function NotFound() {
  return <div>404 - 找不到页面</div>;
}

处理未捕获异常

嵌套错误边界

在任意路由段放 error.tsx 就会为该子树建立错误边界。必须加 'use client'

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

export default function ErrorPage({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  useEffect(() => console.error(error), [error]);

  return (
    <div>
      <h2>出错了</h2>
      <button onClick={() => unstable_retry()}>重试</button>
    </div>
  );
}

错误会冒泡到最近的 error.tsx,因此你可以分层定制。例如 /dashboard/settings/error.tsx 会兜底设置页,其它页面则走上层 error.tsx

更细粒度:unstable_catchError

想把错误边界挂到组件级别,可以用 unstable_catchError

'use client';
import { unstable_catchError as catchError, type ErrorInfo } from 'next/error';

function Fallback(
  props: { title: string },
  { error, unstable_retry }: ErrorInfo
) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => unstable_retry()}>重试</button>
    </div>
  );
}

export default catchError(Fallback);

事件里的错误

错误边界只捕获渲染期错误。事件处理函数、async 回调里的错误要自己 try/catch 并用 useState 存起来:

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

export function Button() {
  const [error, setError] = useState<any>(null);
  function handleClick() {
    try {
      throw new Error('Oops');
    } catch (e) {
      setError(e);
    }
  }
  if (error) return <p>发生错误:{error.message}</p>;
  return <button onClick={handleClick}>点我</button>;
}

注意:startTransition 里的错误会 冒泡到最近的错误边界

全局兜底:global-error.js

根布局崩了怎么办?在 app/global-error.tsx 提供最外层兜底,它会替换根布局:

'use client';

export default function GlobalError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <html>
      <body>
        <h2>出错了</h2>
        <button onClick={() => unstable_retry()}>重试</button>
      </body>
    </html>
  );
}

注意 global-error 自己必须写 <html><body>

錯誤處理

錯誤可分為兩類:

  • 可預期錯誤:表單驗證失敗、請求失敗 — 把它們視為「回傳值」,切勿 throw
  • 未捕捉例外:真正的 bug — 拋出去,由錯誤邊界兜底。

處理可預期錯誤

Server Function

驗證失敗、回應非 2xx,應返回錯誤物件:

'use server';
export async function createPost(prevState: any, formData: FormData) {
  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: JSON.stringify({
      title: formData.get('title'),
      content: formData.get('content'),
    }),
  });
  if (!res.ok) return { message: '建立失敗' };
  return { message: '' };
}

客戶端以 useActionState 讀取:

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, { message: '' });
  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>送出</button>
    </form>
  );
}

於伺服器元件中處理

直接依回應決定渲染分支或重新導向:

export default async function Page() {
  const res = await fetch('https://…');
  if (!res.ok) return <p>發生錯誤</p>;
  const data = await res.json();
  return <Posts data={data} />;
}

404:notFound

在路由段內呼叫 notFound() 會渲染最接近的 not-found.tsx

import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/posts';

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound();
  return <div>{post.title}</div>;
}

配套 app/blog/not-found.tsx

export default function NotFound() {
  return <div>404 - 找不到頁面</div>;
}

處理未捕捉例外

巢狀錯誤邊界

在任何路由段放 error.tsx,即會為該子樹建立錯誤邊界。必須加 'use client'

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

export default function ErrorPage({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  useEffect(() => console.error(error), [error]);

  return (
    <div>
      <h2>發生錯誤</h2>
      <button onClick={() => unstable_retry()}>重試</button>
    </div>
  );
}

錯誤會冒泡至最近的 error.tsx,因此你可以分層客製。例如 /dashboard/settings/error.tsx 只兜底設定頁,其他頁面走上層 error.tsx

更細粒度:unstable_catchError

若想把錯誤邊界掛到元件層級,可用 unstable_catchError

'use client';
import { unstable_catchError as catchError, type ErrorInfo } from 'next/error';

function Fallback(
  props: { title: string },
  { error, unstable_retry }: ErrorInfo
) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => unstable_retry()}>重試</button>
    </div>
  );
}

export default catchError(Fallback);

事件中的錯誤

錯誤邊界只會捕捉渲染期錯誤。事件處理函式、async callback 內的錯誤需自行 try/catch 並以 useState 儲存:

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

export function Button() {
  const [error, setError] = useState<any>(null);
  function handleClick() {
    try {
      throw new Error('Oops');
    } catch (e) {
      setError(e);
    }
  }
  if (error) return <p>發生錯誤:{error.message}</p>;
  return <button onClick={handleClick}>點我</button>;
}

注意:startTransition 內的錯誤會 冒泡至最近的錯誤邊界

全域兜底:global-error.js

如根佈局崩潰怎麼辦?在 app/global-error.tsx 提供最外層兜底,它會替換根佈局:

'use client';

export default function GlobalError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <html>
      <body>
        <h2>發生錯誤</h2>
        <button onClick={() => unstable_retry()}>重試</button>
      </body>
    </html>
  );
}

注意 global-error 自身必須寫上 <html><body>

Error Handling

Errors come in two flavours:

  • Expected errors: form validation, failed requests — return them as values; don't throw.
  • Uncaught exceptions: actual bugs — throw them so error boundaries can catch them.

Handling expected errors

Server Functions

For validation failures or non-2xx responses, return an error object:

'use server';
export async function createPost(prevState: any, formData: FormData) {
  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: JSON.stringify({
      title: formData.get('title'),
      content: formData.get('content'),
    }),
  });
  if (!res.ok) return { message: 'Failed to create post' };
  return { message: '' };
}

Consume it with useActionState on the client:

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, { message: '' });
  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>Submit</button>
    </form>
  );
}

In Server Components

Branch or redirect based on the response:

export default async function Page() {
  const res = await fetch('https://…');
  if (!res.ok) return <p>Something went wrong</p>;
  const data = await res.json();
  return <Posts data={data} />;
}

404: notFound

Call notFound() inside a segment to render the nearest not-found.tsx:

import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/posts';

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound();
  return <div>{post.title}</div>;
}

Pair with app/blog/not-found.tsx:

export default function NotFound() {
  return <div>404 - Page not found</div>;
}

Handling uncaught exceptions

Nested error boundaries

Drop an error.tsx inside any segment to create an error boundary for that subtree. It must be a Client Component:

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

export default function ErrorPage({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  useEffect(() => console.error(error), [error]);

  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => unstable_retry()}>Try again</button>
    </div>
  );
}

Errors bubble up to the closest error.tsx, so you can define them per subtree. /dashboard/settings/error.tsx handles the settings page; other pages fall back to a higher boundary.

Component-level: unstable_catchError

For even finer boundaries, wrap a component with unstable_catchError:

'use client';
import { unstable_catchError as catchError, type ErrorInfo } from 'next/error';

function Fallback(
  props: { title: string },
  { error, unstable_retry }: ErrorInfo
) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => unstable_retry()}>Try again</button>
    </div>
  );
}

export default catchError(Fallback);

Event handlers

Error boundaries only catch errors during rendering. For event handlers and async callbacks, try/catch yourself and store in state:

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

export function Button() {
  const [error, setError] = useState<any>(null);
  function handleClick() {
    try {
      throw new Error('Oops');
    } catch (e) {
      setError(e);
    }
  }
  if (error) return <p>Error: {error.message}</p>;
  return <button onClick={handleClick}>Click me</button>;
}

Note: errors inside startTransition do bubble up to the nearest error boundary.

Last-resort: global-error.js

What if the root layout itself crashes? app/global-error.tsx replaces the root layout and must include its own <html> and <body>:

'use client';

export default function GlobalError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <html>
      <body>
        <h2>Something went wrong</h2>
        <button onClick={() => unstable_retry()}>Try again</button>
      </body>
    </html>
  );
}