nextjs-app-router

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
This skill builds on [server-setup], [client-setup], [react-query-setup], and [adapter-fetch]. Read them first for foundational concepts.
本技能基于[server-setup]、[client-setup]、[react-query-setup]和[adapter-fetch]构建。请先阅读这些内容了解基础概念。

tRPC -- Next.js App Router

tRPC -- Next.js App Router

File Structure

文件结构

.
├── app
│   ├── api/trpc/[trpc]
│   │   └── route.ts        # tRPC HTTP handler
│   ├── layout.tsx           # mount TRPCReactProvider
│   ├── page.tsx             # server component (prefetch)
│   └── client-greeting.tsx  # client component (consume)
├── trpc
│   ├── init.ts              # initTRPC, createTRPCContext
│   ├── routers
│   │   └── _app.ts          # main app router, AppRouter type
│   ├── query-client.ts      # shared QueryClient factory
│   ├── client.tsx           # client hooks & TRPCReactProvider
│   └── server.tsx           # server-side proxy & helpers
└── ...
.
├── app
│   ├── api/trpc/[trpc]
│   │   └── route.ts        # tRPC HTTP处理器
│   ├── layout.tsx           # 挂载TRPCReactProvider
│   ├── page.tsx             # 服务端组件(预取)
│   └── client-greeting.tsx  # 客户端组件(消费)
├── trpc
│   ├── init.ts              # initTRPC, createTRPCContext
│   ├── routers
│   │   └── _app.ts          # 主应用路由, AppRouter类型
│   ├── query-client.ts      # 共享QueryClient工厂
│   ├── client.tsx           # 客户端钩子 & TRPCReactProvider
│   └── server.tsx           # 服务端代理 & 工具函数
└── ...

Setup

设置步骤

1. Install

1. 安装

sh
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query zod server-only client-only
sh
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query zod server-only client-only

2. Server init and context

2. 服务端初始化与上下文

ts
import { initTRPC } from '@trpc/server';

export const createTRPCContext = async (opts: { headers: Headers }) => {
  return { userId: 'user_123' };
};

const t = initTRPC
  .context<Awaited<ReturnType<typeof createTRPCContext>>>()
  .create();

export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
ts
import { initTRPC } from '@trpc/server';

export const createTRPCContext = async (opts: { headers: Headers }) => {
  return { userId: 'user_123' };
};

const t = initTRPC
  .context<Awaited<ReturnType<typeof createTRPCContext>>>()
  .create();

export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;

3. Define the router

3. 定义路由

ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';

export const appRouter = createTRPCRouter({
  hello: baseProcedure
    .input(z.object({ text: z.string() }))
    .query(({ input }) => ({
      greeting: `hello ${input.text}`,
    })),
});

export type AppRouter = typeof appRouter;
ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';

export const appRouter = createTRPCRouter({
  hello: baseProcedure
    .input(z.object({ text: z.string() }))
    .query(({ input }) => ({
      greeting: `hello ${input.text}`,
    })),
});

export type AppRouter = typeof appRouter;

4. Route handler (API endpoint)

4. 路由处理器(API端点)

ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '../../../../trpc/init';
import { appRouter } from '../../../../trpc/routers/_app';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
  });

export { handler as GET, handler as POST };
ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '../../../../trpc/init';
import { appRouter } from '../../../../trpc/routers/_app';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
  });

export { handler as GET, handler as POST };

5. QueryClient factory

5. QueryClient工厂

ts
import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });
}
If using a data transformer (e.g., superjson), add
dehydrate.serializeData
and
hydrate.deserializeData
here.
ts
import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });
}
如果使用数据转换器(如superjson),请在此处添加
dehydrate.serializeData
hydrate.deserializeData

6. Client provider (client component)

6. 客户端提供者(客户端组件)

tsx
'use client';

import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { useState } from 'react';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';

export const { TRPCProvider, useTRPC, useTRPCClient } =
  createTRPCContext<AppRouter>();

let browserQueryClient: QueryClient;
function getQueryClient() {
  if (typeof window === 'undefined') {
    return makeQueryClient();
  }
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}

function getUrl() {
  const base = (() => {
    if (typeof window !== 'undefined') return '';
    if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
    return 'http://localhost:3000';
  })();
  return `${base}/api/trpc`;
}

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: getUrl(),
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {props.children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}
tsx
'use client';

import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { useState } from 'react';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';

export const { TRPCProvider, useTRPC, useTRPCClient } =
  createTRPCContext<AppRouter>();

let browserQueryClient: QueryClient;
function getQueryClient() {
  if (typeof window === 'undefined') {
    return makeQueryClient();
  }
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}

function getUrl() {
  const base = (() => {
    if (typeof window !== 'undefined') return '';
    if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
    return 'http://localhost:3000';
  })();
  return `${base}/api/trpc`;
}

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: getUrl(),
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {props.children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}

7. Server-side proxy (server component)

7. 服务端代理(服务端组件)

tsx
import 'server-only';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { TRPCQueryOptions } from '@trpc/tanstack-react-query';
import { headers } from 'next/headers';
import { cache } from 'react';
import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';

export const getQueryClient = cache(makeQueryClient);

export const trpc = createTRPCOptionsProxy({
  ctx: async () =>
    createTRPCContext({
      headers: await headers(),
    }),
  router: appRouter,
  queryClient: getQueryClient,
});

export function HydrateClient(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {props.children}
    </HydrationBoundary>
  );
}

export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(
  queryOptions: T,
) {
  const queryClient = getQueryClient();
  if (queryOptions.queryKey[1]?.type === 'infinite') {
    void queryClient.prefetchInfiniteQuery(queryOptions as any);
  } else {
    void queryClient.prefetchQuery(queryOptions);
  }
}
tsx
import 'server-only';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { TRPCQueryOptions } from '@trpc/tanstack-react-query';
import { headers } from 'next/headers';
import { cache } from 'react';
import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';

export const getQueryClient = cache(makeQueryClient);

export const trpc = createTRPCOptionsProxy({
  ctx: async () =>
    createTRPCContext({
      headers: await headers(),
    }),
  router: appRouter,
  queryClient: getQueryClient,
});

export function HydrateClient(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {props.children}
    </HydrationBoundary>
  );
}

export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(
  queryOptions: T,
) {
  const queryClient = getQueryClient();
  if (queryOptions.queryKey[1]?.type === 'infinite') {
    void queryClient.prefetchInfiniteQuery(queryOptions as any);
  } else {
    void queryClient.prefetchQuery(queryOptions);
  }
}

8. Mount provider in layout

8. 在布局中挂载提供者

tsx
import { TRPCReactProvider } from '../trpc/client';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}
tsx
import { TRPCReactProvider } from '../trpc/client';

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

Core Patterns

核心模式

Prefetch in server component, consume in client component

在服务端组件预取,在客户端组件消费

tsx
import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  prefetch(trpc.hello.queryOptions({ text: 'world' }));

  return (
    <HydrateClient>
      <ClientGreeting />
    </HydrateClient>
  );
}
tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';

export function ClientGreeting() {
  const trpc = useTRPC();
  const greeting = useQuery(trpc.hello.queryOptions({ text: 'world' }));
  if (!greeting.data) return <div>Loading...</div>;
  return <div>{greeting.data.greeting}</div>;
}
tsx
import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  prefetch(trpc.hello.queryOptions({ text: 'world' }));

  return (
    <HydrateClient>
      <ClientGreeting />
    </HydrateClient>
  );
}
tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';

export function ClientGreeting() {
  const trpc = useTRPC();
  const greeting = useQuery(trpc.hello.queryOptions({ text: 'world' }));
  if (!greeting.data) return <div>Loading...</div>;
  return <div>{greeting.data.greeting}</div>;
}

Suspense with prefetch

搭配预取的Suspense

tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  prefetch(trpc.hello.queryOptions({ text: 'world' }));

  return (
    <HydrateClient>
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <Suspense fallback={<div>Loading...</div>}>
          <ClientGreeting />
        </Suspense>
      </ErrorBoundary>
    </HydrateClient>
  );
}
tsx
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';

export function ClientGreeting() {
  const trpc = useTRPC();
  const { data } = useSuspenseQuery(trpc.hello.queryOptions({ text: 'world' }));
  return <div>{data.greeting}</div>;
}
tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  prefetch(trpc.hello.queryOptions({ text: 'world' }));

  return (
    <HydrateClient>
      <ErrorBoundary fallback={<div>出现错误</div>}>
        <Suspense fallback={<div>加载中...</div>}>
          <ClientGreeting />
        </Suspense>
      </ErrorBoundary>
    </HydrateClient>
  );
}
tsx
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';

export function ClientGreeting() {
  const trpc = useTRPC();
  const { data } = useSuspenseQuery(trpc.hello.queryOptions({ text: 'world' }));
  return <div>{data.greeting}</div>;
}

Direct server caller (data needed on server only)

直接服务端调用器(仅服务端需要数据)

tsx
// Add to existing server.tsx
export const caller = appRouter.createCaller(async () =>
  createTRPCContext({ headers: await headers() }),
);
tsx
import { caller } from '../trpc/server';

export default async function Home() {
  const greeting = await caller.hello({ text: 'world' });
  return <div>{greeting.greeting}</div>;
}
Note:
caller
results are not stored in the query cache. They cannot hydrate to client components. Use
prefetchQuery
if client components also need the data.
tsx
// 添加到已有的server.tsx
export const caller = appRouter.createCaller(async () =>
  createTRPCContext({ headers: await headers() }),
);
tsx
import { caller } from '../trpc/server';

export default async function Home() {
  const greeting = await caller.hello({ text: 'world' });
  return <div>{greeting.greeting}</div>;
}
注意:
caller
的结果不会存储在查询缓存中,无法水合到客户端组件。如果客户端组件也需要该数据,请使用
prefetchQuery

fetchQuery for data on server AND client

fetchQuery用于服务端和客户端都需要的数据

tsx
import { getQueryClient, HydrateClient, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  const queryClient = getQueryClient();
  const greeting = await queryClient.fetchQuery(
    trpc.hello.queryOptions({ text: 'world' }),
  );

  // Use greeting on the server
  console.log(greeting.greeting);

  return (
    <HydrateClient>
      <ClientGreeting />
    </HydrateClient>
  );
}
tsx
import { getQueryClient, HydrateClient, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';

export default async function Home() {
  const queryClient = getQueryClient();
  const greeting = await queryClient.fetchQuery(
    trpc.hello.queryOptions({ text: 'world' }),
  );

  // 在服务端使用greeting
  console.log(greeting.greeting);

  return (
    <HydrateClient>
      <ClientGreeting />
    </HydrateClient>
  );
}

Common Mistakes

常见错误

Not exporting both GET and POST from route handler

未从路由处理器导出GET和POST方法

Next.js App Router route handlers must export named
GET
and
POST
functions. Missing either causes queries or mutations to return 405 Method Not Allowed.
ts
// WRONG
export default function handler(req: Request) { ... }

// CORRECT
const handler = (req: Request) =>
  fetchRequestHandler({ req, router: appRouter, endpoint: '/api/trpc', createContext });
export { handler as GET, handler as POST };
Next.js App Router的路由处理器必须导出命名的
GET
POST
函数。缺少任意一个都会导致查询或变更返回405 Method Not Allowed错误。
ts
// 错误写法
export default function handler(req: Request) { ... }

// 正确写法
const handler = (req: Request) =>
  fetchRequestHandler({ req, router: appRouter, endpoint: '/api/trpc', createContext });
export { handler as GET, handler as POST };

Creating a singleton QueryClient for SSR

为SSR创建单例QueryClient

In server components, each request needs its own
QueryClient
instance. A singleton leaks data between requests.
ts
// WRONG
const queryClient = new QueryClient(); // shared across requests!

// CORRECT
export const getQueryClient = cache(makeQueryClient);
The
cache()
wrapper from React ensures the same
QueryClient
is reused within a single request but a new one is created for each new request.
在服务端组件中,每个请求都需要独立的
QueryClient
实例。单例会导致请求之间的数据泄露。
ts
// 错误写法
const queryClient = new QueryClient(); // 跨请求共享!

// 正确写法
export const getQueryClient = cache(makeQueryClient);
React的
cache()
包装器确保在单个请求内复用同一个
QueryClient
,但为每个新请求创建新实例。

Missing dehydrate/shouldDehydrateQuery config

缺少dehydrate/shouldDehydrateQuery配置

RSC hydration requires
shouldDehydrateQuery
to include pending queries so that prefetched-but-not-yet-resolved promises can stream to the client. Without this, prefetched queries may not appear in the hydrated state.
ts
// WRONG
new QueryClient(); // default shouldDehydrateQuery skips pending

// CORRECT
new QueryClient({
  defaultOptions: {
    dehydrate: {
      shouldDehydrateQuery: (query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
    },
  },
});
RSC水合需要
shouldDehydrateQuery
包含pending状态的查询,这样预取但尚未解析的Promise才能流式传输到客户端。如果没有该配置,预取的查询可能不会出现在水合后的状态中。
ts
// 错误写法
new QueryClient(); // 默认的shouldDehydrateQuery会跳过pending状态的查询

// 正确写法
new QueryClient({
  defaultOptions: {
    dehydrate: {
      shouldDehydrateQuery: (query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
    },
  },
});

Suspense query failure crashes entire page during SSR

Suspense查询失败在SSR期间导致整个页面崩溃

If a query fails during SSR with
useSuspenseQuery
, the entire page crashes. Error Boundaries only catch errors on the client side. For critical pages, either handle errors server-side before rendering, or use
useQuery
(non-suspense) which allows graceful degradation.
如果在SSR期间使用
useSuspenseQuery
的查询失败,整个页面会崩溃。错误边界仅在客户端捕获错误。对于关键页面,要么在渲染前在服务端处理错误,要么使用
useQuery
(非Suspense版本)以实现优雅降级。

See Also

另请参阅

  • [react-query-setup] -- TanStack React Query setup, queryOptions/mutationOptions factories
  • [adapter-fetch] -- fetchRequestHandler for edge/serverless runtimes
  • [server-setup] -- initTRPC, routers, procedures, context
  • [nextjs-pages-router] -- if maintaining a Pages Router project alongside App Router
  • [react-query-setup] -- TanStack React Query设置,queryOptions/mutationOptions工厂
  • [adapter-fetch] -- 用于边缘/无服务器运行时的fetchRequestHandler
  • [server-setup] -- initTRPC、路由、流程、上下文
  • [nextjs-pages-router] -- 如果需要同时维护Pages Router项目和App Router项目