tRPC v10 服务端渲染(SSR)实战指南:基于 Next.js Pages Router 的 `ssr: true` 配置、请求头转发与响应缓存
本文档对应仓库中的 version-10.x SSR 指南,是一份面向 tRPC v10 + Next.js Pages Router 的端到端 SSR 接入手册。
本篇技术指南以 tRPC v10 文档中的 SSR 章节为核心,系统讲解如何在 Next.js Pages Router 应用中通过 @trpc/next 的 createTRPCNext 开启服务端渲染:从最简的 ssr: true 一行开关、浏览器/服务端双环境 config 拆分、cookie 请求头转发、条件式 SSR 判断,到 responseMeta 响应缓存,以及与 getServerSideProps/getStaticProps 混用时的取舍。读完本文,你将能独立为 tRPC 10 应用配置出数据在服务端完成预取(prefetch)、客户端零首屏空载的 SSR 链路,并理解其背后 getInitialProps 与 hydration 的工作机制。
关联文档与适用版本说明
本文的主体内容是仓库中 ssr.md(位于 www/versioned_docs/version-10.x/client/nextjs/ 目录,与 setup.mdx、server-side-helpers.md、ssg.md 同级)。该文档针对 tRPC v10 的 Pages Router 用法,代码基于 pages/ 目录 + getInitialProps/getServerSideProps/getStaticProps。同一主题在 v11 文档中被迁移至 www/docs/client/nextjs/pages-router/ssr.md,二者的核心概念一致,但 v11 需要额外传入 ssrPrepass(本文「底层原理」一节会结合仓库源码解释这一演进)。
开启 SSR:从一行 ssr: true 开始
要让 tRPC 查询在 Next.js 服务端渲染阶段被执行,只需要在 createTRPCNext 的配置对象中设置 ssr: true:
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from './api/trpc/[trpc]';
export const trpc = createTRPCNext<AppRouter>({
config(config) {
// ...
},
ssr: true,
});
关键点在于:
- 默认值是关闭的(
ssr不传即为false)。在默认情况下,所有查询只会在浏览器端通过useQuery触发 HTTP 请求。 - 开启后,tRPC 会利用
getInitialProps在服务端预取(prefetch)所有查询,把结果随页面首屏 HTML 一并下发,客户端渲染时直接读取已就绪的数据,不再出现“先 loading、再请求”的空窗。
:::info
官方文档特别提醒一个版本性约束:开启 SSR 后 tRPC 依赖 getInitialProps 完成服务端预取,这与 Next.js 的 getServerSideProps 一起使用会产生已知问题(tRPC 仓库 issue #596 一类的报错,例如 getServerSideProps 会接管页面数据流、干扰 getInitialProps 注入的 trpcState),而该问题并不在 tRPC 的可解决范围内。因此:
- 选用 SSR 方案:页面请走
getInitialProps链路; - 必须使用
getServerSideProps/getStaticProps:建议保持 SSR 关闭(默认),改用 Server-Side Helpers(服务端辅助函数) 手动预取查询并脱水传递。这也是 SSG 静态生成指南 的推荐路径。 :::
让查询在服务端正确执行:拆分浏览器与服务端 config
仅仅打开开关还不够。createTRPCNext 的 config(config) 回调在服务端渲染与浏览器端都会被调用,而两者运行环境截然不同,因此文档要求在其中补充额外逻辑——通常写法是用 typeof window !== 'undefined' 做环境判断,分别返回两套链接配置:
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from './api/trpc/[trpc]';
export const trpc = createTRPCNext<AppRouter>({
config(config) {
const { ctx } = opts; // NextPageContext,仅服务端存在 req/res
if (typeof window !== 'undefined') {
// during client requests
return {
transformer: superjson, // optional - adds superjson serialization
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
return {
transformer: superjson, // optional - adds superjson serialization
links: [
httpBatchLink({
// The server needs to know your app's full url
url: `${getBaseUrl()}/api/trpc`,
/**
* Set custom request headers on every request from tRPC
*/
headers() {
if (!ctx?.req?.headers) {
return {};
}
// To use SSR properly, you need to forward client headers to the server
// This is so you can pass through things like cookies when we're server-side rendering
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
ssr: true,
});
这段代码里有四个必须理解的细节:
- 浏览器端使用相对路径
/api/trpc:同源下由浏览器直接发起请求,无需知道应用全量 URL。 - 服务端必须使用全量 URL:SSR 阶段的数据请求是由 Node.js 服务端发起的 HTTP 调用(而非浏览器),因此要用
${getBaseUrl()}/api/trpc拼出应用的完整地址(如http://localhost:3000/api/trpc)。getBaseUrl()是你自己维护的辅助函数,典型实现是“浏览器端返回空字符串(走相对路径),服务端返回部署后的绝对地址”。 headers()回调转发 cookie 等客户端头:config回调接收的参数(形参名可以叫config/ctx/opts,本文与文档代码里混用了命名)内含ctx,即 Next.js 的NextPageContext;只有在服务端它才携带ctx.req.headers。把req.headers.cookie原样转发出去,才能让后端拿到与浏览器一致的登录态/会话信息——否则 SSR 渲染出的页面会因为“没有 cookie”而拿到与客户端不同的数据。若拿不到ctx.req.headers(例如某些上下文缺失的场景),则返回空对象兜底。transformer: superjson为可选项:用于序列化Date、Map等 JS 特有类型,需要保证客户端与服务端一致。
为什么不能只靠相对路径 + 浏览器头?
原因就在第 2、3 点:服务端渲染发生在 Node 进程里,此处“客户端”是 Node 的 fetch 实现,既不认识浏览器的 location,也不会自动携带用户的 cookie。所以这两段服务端专属逻辑是 SSR 能否“渲染出正确数据”的分水岭。
条件式 SSR:只对特定请求开启预取
如果不想对所有请求都做 SSR,可以把 ssr 从布尔值改成一个回调函数。该回调会在服务端被调用,接收 opts(内含 opts.ctx),既可以同步返回布尔值,也可以返回一个 resolve 为布尔值的 Promise:
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from './api/trpc/[trpc]';
export const trpc = createTRPCNext<AppRouter>({
config(config) {
const { ctx } = opts;
if (typeof window !== 'undefined') {
// during client requests
return {
transformer: superjson, // optional - adds superjson serialization
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
return {
transformer: superjson, // optional - adds superjson serialization
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
if (!ctx?.req?.headers) {
return {};
}
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
ssr(opts) {
// only SSR if the request is coming from a bot
return opts.ctx?.req?.headers['user-agent']?.includes('bot');
},
});
文档给出的示例是一个常见的 SEO 场景:只对爬虫/机器人请求做服务端渲染,从而让搜索引擎拿到完整 HTML,而普通用户在客户端享受 SPA 的交互体验——这是对性能和 SEO 的一种经典折中。请注意:从仓库当前源码的类型定义可以推断,该回调的签名是 (opts: { ctx: NextPageContext }) => boolean | Promise<boolean>,其中 opts.ctx 即 Next.js 的页面上下文,因此可以读取 user-agent、cookie 等任意请求头作为判定依据(见 withTRPC.tsx 中 WithTRPCSSROptions.ssr 的类型声明)。由于回调允许返回 Promise,你甚至可以在其中做异步判断(例如调用某个鉴权/设备识别服务)。
在 _app.tsx 中挂载 Provider:trpc.withTRPC
无论 SSR 是否开启、是否条件化,客户端都需要一个顶层 Provider 注入 QueryClient 与 tRPC 客户端,这一步通过 createTRPCNext 暴露的 withTRPC 高阶组件完成:
import { trpc } from '~/utils/trpc';
import type { AppProps } from 'next/app';
import React from 'react';
const MyApp: AppType = ({ Component, pageProps }: AppProps) => {
return <Component {...pageProps} />;
};
export default trpc.withTRPC(MyApp);
要点:
withTRPC包裹的是 Next.js 的 App 根组件,它内部会创建QueryClientProvider、trpc.Provider以及基于pageProps.trpcState的HydrationBoundary(该机制在 v11 仓库源码中可见,见下文「底层原理」)。- SSR 开启时,
withTRPC会接管getInitialProps:先执行应用自身的数据获取逻辑,再运行服务端预取,最终把脱水后的trpcState合并进pageProps,保证客户端拿到同构的初始数据。
结合仓库源码看底层原理(v10 机制与 v11 的 ssrPrepass 演进)
版本化文档描述的 v10 行为是:ssr: true 时 tRPC 自动经由 getInitialProps 在服务端渲染前预取全部查询,无需额外传参。而在本仓库当前的 @trpc/next 实现(面向 v11)中,这一逻辑被重构为显式的 ssrPrepass 机制,恰好可以作为理解 v10 内部细节的“放大镜”:
- withTRPC.tsx 定义了
WithTRPCSSROptions:开启 SSR 时除了ssr: true | ((opts) => boolean | Promise<boolean>),还必须提供ssrPrepass与可选的responseMeta。 - ssrPrepass.ts 展示了完整流水线:在服务端调用
parent.config({ ctx })创建 tRPC 客户端与QueryClient,然后用react-dom/server的renderToString循环渲染组件树——只要一次渲染引发了新的查询抓取(queryClient.isFetching()为真)就继续下一轮,直至数据全部稳定,从而天然支持 HTTP 批量请求(batching)的逐轮结算;最终用dehydrate导出缓存并序列化为pageProps['trpcState']传给客户端。 - 渲染期间
ssrState被标记为prepass(见 ssrPrepass.ts);tRPC 客户端内部用SSRState = 'mounted' | 'mounting' | 'prepass' | false来区分渲染阶段并决定是否发起客户端请求(见 context.tsx)。 - 脱水出的错误会被规范化为
TRPCClientErrorLike对象再序列化(ssrPrepass.ts),避免Error实例无法跨环境传输的问题。
一句话总结:v10 用 getInitialProps 自动预取,v11 用 ssrPrepass 的“预渲染 + 循环脱水”完成同样目标。阅读 v10 部署代码时若发现 TRPCClientError: fetch failed 或 trpcState 缺失,多半需要回到「双环境 config 与请求头转发」检查。
为 SSR 页面叠加响应缓存:responseMeta 与 cache-control
SSR 会显著增加每个请求的渲染开销。文档建议 SSR 开启后结合 服务端响应缓存 使用:给 SSR 产出的 HTML 响应加上 cache-control 头,让 CDN/边缘网络兜住重复流量(对公开、不含个人信息的路由尤其有效)。
缓存逻辑通过 createTRPCNext 的 responseMeta 回调注入——它会在 SSR 结束时被调用,接收 { ctx, clientErrors },返回 { status?, headers? }。下面是从该缓存文档中摘出的典型用法(v10 版本):
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCNext<AppRouter>({
config(config) {
if (typeof window !== 'undefined') {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
const url = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: 'http://localhost:3000/api/trpc';
return {
links: {
http: httpBatchLink({
url,
}),
},
};
},
ssr: true,
responseMeta(opts) {
const { clientErrors } = opts;
if (clientErrors.length) {
// propagate http first error from API calls
return {
status: clientErrors[0].data?.httpStatus ?? 500,
};
}
// cache request for 1 day + revalidate once every second
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
return {
headers: {
'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
},
};
},
});
这里的要点:
- 有错误时不缓存并透传状态码:遍历
clientErrors,取第一个错误的 HTTP 状态码(缺省回退 500),避免把出错页面缓存成“正常页”。 - 成功时下发
s-maxage=1, stale-while-revalidate=86400:CDN 层缓存 1 秒后进入“后台异步重新验证”的 stale-while-revalidate 模式,配合 SSG 思路可以让整个应用接近静态站点速度。 - 服务端地址仍可用
process.env.VERCEL_URL之类的部署环境变量动态拼出,而非硬编码。
:::warning
缓存前务必确认响应不含个人信息。tRPC 默认启用批量请求(batching),一个 HTTP 响应可能同时携带多条查询结果;如果其中混入鉴权数据,建议在有 cookie/auth 头时跳过缓存,或用 splitLink 把公共查询与私有查询拆到不同链路。相关讨论详见 服务端响应缓存。
:::
FAQ:SSR 接入中的高频问题
文档末尾整理了三个几乎人人都会踩的坑,这里完整保留并补充实现层面的解读。
Q1:为什么必须手动把客户端请求头转发给服务端?tRPC 不能自动做吗?
虽然绝大多数 SSR 场景都想转发客户端头,但服务端链路上你常常需要在转发的基础上动态增删头(例如临时附加某个上游 token、改写 header 键避免冲突)。tRPC 不愿意替你承担“header 键冲突、覆盖顺序”这类责任,所以把转发策略完全交给你在 headers() 中控制——这也正是上述 config 里单独为服务端写 cookie 转发的原因。
Q2:在 Node 18 上做 SSR 时,为什么需要删除 connection 请求头?
如果不移除 connection 头,数据抓取会以 TRPCClientError: fetch failed 失败。原因在于 connection 属于 HTTP 规范中的 forbidden header name(禁用头名),浏览器/符合规范的 fetch 实现禁止手动设置它。当你在服务端从 Node 发起请求却把旧头原样带上时,便会触发该错误;标准的解法是在服务端构建请求头前先剔除 connection。
Q3:明明 SSR 已经返回了初始数据,为什么 Network 面板里还能看到请求?
因为 @tanstack/react-query(tRPC 数据抓取 Hook 的底层库)默认会在组件挂载(mount)与窗口重新聚焦(window refocus)时重新抓取数据,即使它已经通过 SSR 拿到了初始数据——这是为了保证数据始终新鲜,并非 SSR 失效。若你的页面数据基本静态、希望完全避免重复请求,可关闭该行为:参考 SSG 静态生成指南 中的做法,在查询选项里设置 refetchOnMount: false 与 refetchOnWindowFocus: false,或在 createTRPCNext 的 queryClientConfig.defaultOptions.queries 中全局关闭。
小结:v10 SSR 的决策树
- 需要全站/多数页面 SSR,且页面走
getInitialProps→ 直接ssr: true,并在config里实现“双环境链接 + cookie 转发”; - 只想对爬虫等特定请求 SSR →
ssr传回调,按ctx.req.headers['user-agent']等判定; - 页面使用
getServerSideProps/getStaticProps→ 不要开ssr: true,改用 Server-Side Helpers 预取并在 props 里返回trpcState: helpers.dehydrate(); - 追求极致首屏速度、内容公开无隐私 → 叠加
responseMeta下发cache-control,参考 响应缓存指南; - SSR 与数据抓取行为相关的更多说明,可继续阅读 SSG 与 Server-Side Helpers;同时仓库示例目录
examples/中提供了大量可运行的 Next.js + tRPC 项目供对照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K635- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python70
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java161
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java90
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript120
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python300