1. t3code 是什么:一套从实战里长出来的全栈代码模板
先交代一下背景。t3code 是我在连续做完三四个中大型 TypeScript 全栈项目之后,沉淀出来的一套代码模板和工程规范。说实话,最早起这个名字纯粹是因为它基于社区里很火的 T3 Stack 思路(TypeScript + Tailwind CSS + tRPC + Next.js),但后来随着项目越用越顺手,我在里面加入了不少自己的组织方式,t3code 渐渐从"一个脚手架"变成了"一套我自己信得过的全栈起点工程"。
t3code 能解决什么问题?举个最现实的场景:以前每开一个新项目,TypeScript 类型要重新搭,API 路由要重新写,数据库 ORM 要重新配,认证逻辑要重新接,前端拿接口时要自己维护类型定义,接口字段一变前后端还会对不上。这些事情单独看都不是大问题,但叠在一起,每次至少浪费三到五天的冷启动时间。t3code 把这些全部固化下来,新项目拉下来就能跑,业务代码直接从第一行开始写。
这篇文章适合谁看?如果你是写 TypeScript 全栈应用、用 Next.js 做服务端渲染、对类型安全有执念的开发者,或者你正在纠结要不要上 tRPC、怎么组织自己的工程模板,那你可以把它当成一份实战笔记翻完。文章里没有教科书式的概念定义,全是我在真实业务里用 t3code 跑项目时踩过的坑、调过的参、以及最后固定下来的做法。
2. 整体设计与技术选型思路:为什么选这套方案而不是那套
2.1 核心需求拆解:先搞清楚模板到底要解决什么
我构建 t3code 之前的思考路径是这样的:作为一个代码模板,它不应该是"大而全的依赖集合",而应该是"一套恰好覆盖高频需求的默认配置"。所以我先列了四层需求,每一层对应一类工程问题。
第一层是开发体验。我需要一个新项目从克隆到本地跑起来,时间控制在十分钟以内,而且开发时的热更新、类型检查、格式化、提交前检查都是可用的。第二层是类型安全,这也是 t3code 最核心的定位,从数据库表结构到 API 返回体,再到前端组件消费的数据,一条链路全部有类型约束,而不是靠文档和运气。第三层是工程质量,包括目录规范、代码检查、环境变量管理、数据库迁移流程这些"不写代码时看不到但一旦出问题就很头疼"的部分。第四层是部署可维护性,模板里的东西必须能在几天甚至几周后被我或者队友轻松接手,配置要集中放在一眼能看到的地方。
基于这四层需求,我对比过好几种方案组合。最原始的做法是自己手写 Express 或者 Fastify 后端,再配一个 React 前端,通过 REST 接口通信。这种方式的问题是前后端联系全靠手工维护,接口多了之后类型断言满天飞,改一个字段要全局搜索替换。另一种做法是上 GraphQL,但团队心智负担确实不低,对于中型业务来说,scheme 定义的复杂度有时候比业务本身还高。最后我选定了 T3 Stack 的核心理念,然后按自己的需求做了取舍。
2.2 技术栈选型:TypeScript、Next.js、tRPC、Tailwind、Prisma、Zod 的组合逻辑
先解释一下这个组合里几个关键角色的分工。Next.js 负责应用骨架和渲染层,App Router 这套路由既处理页面也处理 API 路由,一个框架搞定前后端。tRPC 负责端到端类型安全的传输,它可以在不写 REST URL、不定义 controller 的情况下,让前端像调用本地函数一样调用后端逻辑,而且类型是自动推导的。Prisma 负责数据层,Schema 文件里定义模型之后,TS 类型和数据库表结构一起生成。Zod 放在最外层,负责所有输入校验,其实 tRPC 的服务端也可以直接用 Zod 做入参校验,这样校验规则和类型定义就是同一份代码。
有人会问,既然 Next.js 有 API Routes,为什么还要用 tRPC?我的回答是,它们解决的是不同层次的问题。API Routes 解决的是"HTTP 端点长什么样的问题",而 tRPC 解决的是"客户端和服务端之间类型如何不割裂的问题"。在实际项目里,tRPC 的 router 内部最终会通过 API Route 来暴露,但你在业务代码中根本不需要关心 URL 长什么样,只管调用就行。
Tailwind CSS 在这套组合里的定位比较特殊。它在 t3code 里不是花哨的样式工具,而是为了减少"样式文件碎片化"的问题。传统 CSS 方案一个业务模块至少配三四个样式文件,Tailwind 把样式放在类名里,写组件的时候样式和结构在同一个位置,这对模板类项目来说是很吃效率的。
2.3 对几个备选方案的对比与取舍:我为什么不选某些流行组合
选型过程中我实际对比过好几个候选方案,这里直接列一下我最后的取舍记录,方便你在自己的项目里做参考。
首先是前端框架候选有 Vite + React、Next.js Pages Router、Next.js App Router。Vite + React 的体验很好,但它是纯 SPA 方案,SEO 和首屏渲染需要额外处理;Pages Router 胜在稳定,但服务端组件、布局嵌套这些新特性在 App Router 里更顺手。App Router 目前的 edge case 比 Pages Router 多一些,但代码模板项目选了它之后,只要你固定自己的写法,不踩激进特性,其实挺稳定。我在 t3code 里默认 App Router。
数据层候选有 Prisma、Drizzle、TypeORM。TypeORM 的装饰器风格和 TS 类型联动不够直接;Drizzle 更轻量、SQL 味道更重,但它生成的类型不如 Prisma 直观。Prisma 的 schema 文件对没接触过的人很友好,迁移工具链也成熟,所以选了 Prisma。
API 层候选有 REST 手写、GraphQL、tRPC。REST 手写最可控,但是类型信息全丢在了接口文档上;GraphQL 抽象能力强,但引入的学习成本和 client 缓存复杂度对中小型项目不划算;tRPC 牺牲了 REST 的"通用性"换来了极致的 TS 集成度。如果你的项目是纯前后端分离、后端还要给第三方提供 API,那 tRPC 不一定合适,但它是内部全栈项目的最佳效率解。
2.4 t3code 的总体架构布局:一图说清请求怎么走
t3code 的整体架构说穿了其实不复杂:浏览器端发起调用,请求先经过 Next.js 的 App Router 页面或服务端组件,组件内部直接调用 tRPC server caller,或者通过客户端 hooks 发起 tRPC 请求,tRPC router 会先经过 Zod 校验输入,然后调用对应的 service 层代码,service 层通过 Prisma 读写数据库,最后返回值全程有 TS 类型推导回前端。
值得强调的一点是:t3code 里的 tRPC 并不是只能做客户端到服务端的远程调用。因为 t3code 大量使用服务端组件(RSC),服务端组件是直接运行在服务器上的,所以它可以直接导入 tRPC router 来调用,不需要走 HTTP 网络。这种模式叫做"server caller",好处是减少一次网络往返,而且类型依然安全。客户端组件如果要有交互,则走常规的 tRPC hooks,两边代码风格统一,没有割裂感。
3. 核心实现细节:路由、类型、数据链路怎么落地
3.1 目录结构与职责划分:新项目克隆下来第一眼就要清晰
t3code 的目录结构是我刻意设计过的,核心原则是"按模块聚合、按类型分区"。我见过很多模板把文件全塞在 pages 和 components 两个大目录里,一开始很好找,项目到两万行代码以后就是灾难。t3code 采用了一种折中方案。
顶层目录保持 Next.js 的约定,src/app 下是路由结构,src/server 下放所有服务端代码,src/components 下放前端组件,src/lib 放工具函数。但关键点在 src/server 内部,我按照业务模块拆成多个子目录,每个子目录包含自己的 router 定义、service 逻辑、以及和业务相关的类型定义。
举个实际例子:一个电商项目会有 product、order、user 三个模块,每个模块在 src/server 下各有一个文件夹,里面包含 router.ts、service.ts、schema.ts 三个文件。schema.ts 放 Zod 校验器,router.ts 汇总该模块的所有 tRPC 接口,service.ts 放实际的业务逻辑。这样定位一个问题的时候,你不需要在两个目录之间跳来跳去,改动一个页面关联的接口时,所有文件都在附近。这个设计说白了就是 colocation(就近放置),只不过更进一步精确到了业务模块维度。
3.2 tRPC Router 的定义方式:类型安全的第一道关
tRPC Router 是 t3code 的类型核心,它的定义方式直接决定整个项目的类型体验。先看一个标准模块的 router 长什么样。
// src/server/product/router.ts import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/trpc"; import { z } from "zod"; import { getProductById, listProducts } from "./service"; export const productRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(20), keyword: z.string().optional(), }) ) .query(async ({ input }) => { return listProducts(input); }), detail: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { return getProductById(input.id); }), });这段代码里有两个关键点。第一个是publicProcedure和protectedProcedure的区分。publicProcedure 允许未登录访问,protectedProcedure 内部会先检查 session,没登录直接抛 UNAUTHORIZED 错误,后面的业务代码根本不需要关心鉴权,这是一个非常省心的约定。第二个是.input()里的 Zod schema 不仅做运行时校验,同时生成静态类型,前端调用这个 query 时,传给input的参数类型会被自动推断出来,多传一个字段、少传一个字段、类型传错,编辑器立刻红色波浪线。
我特别建议在定义 router 时不要直接把业务查询逻辑写在.query()回调里。很多人贪图省事直接写 Prisma 查询,短期确实快,但等这个接口被三四个页面复用的时候,你就得满项目找重复逻辑。t3code 的约定是 router 只做参数校验和业务编排,实际的数据库操作全部放到 service.ts 里,service 接收已经校验好的参数,返回业务对象。
3.3 前端调用链路:从 useQuery 到数据上屏的四步
前端调用 tRPC 的方式在 t3code 里分两类。第一类是客户端组件交互型。页面里有搜索、筛选、分页这种动态交互时,组件声明为use client组件,然后通过 t3code 预生成的 hooks 来调用,代码大概是这样:
// src/components/product/product-list.tsx "use client"; import { api } from "@/trpc/react"; export function ProductList({ initialKeyword }: { initialKeyword: string }) { const [keyword, setKeyword] = useState(initialKeyword); const { data, isLoading, isFetching } = api.product.list.useQuery( { page: 1, pageSize: 20, keyword }, { placeholderData: { items: [], total: 0, page: 1, pageSize: 20 } } ); return ( <div> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} /> {isLoading ? <p>加载中</p> : data?.items.map((item) => <ProductCard key={item.id} product={item} />)} </div> ); }这段代码看起来很普通,但体验上有三个隐形福利:第一,api.product.list.useQuery的入参类型和返回值类型全部自动推导,product 对象里有哪些字段,编辑器直接弹出建议;第二,placeholderData可以给一个和前一次数据同结构的空值,这样切换筛选条件时页面不会白屏闪烁,体验要顺滑很多;第三,tRPC 默认用 React Query 管理缓存,同一个参数重复请求会在客户端直接命中缓存,不需要自己封装请求层。
第二类是服务端组件直取型。比如一个商品详情页要从服务器取数据渲染,不需要客户端交互,那就直接在服务端组件里调用 server caller 拿数据,中间不经过 HTTP。
// src/app/product/[id]/page.tsx import { createCaller } from "@/server/trpc"; export default async function ProductPage({ params }: { params: { id: string } }) { const caller = createCaller(); const product = await caller.product.detail({ id: params.id }); return <ProductDetail product={product} />; }服务端组件直取型的最大价值是少一次网络请求、少一次客户端序列化,首屏性能明显更好。t3code 里两种方式严格分工,静态展示优先用服务端直取,动态交互优先用 hooks,从源头上避免了"每个页面都从客户端拉数据"这个常见设计问题。
3.4 认证与权限控制:把鉴权埋在 procedure 里
t3code 的权限模型是分层的,基于 NextAuth Session 实现。最底层是数据库里的 User 表和 Session 表;中间层是 NextAuth 的配置,负责登录、会话管理、Cookie 处理;最上层是 tRPC procedure 的两档区分,publicProcedure和protectedProcedure。
protectedProcedure 的实现其实不复杂,它只是在校验输入之前先通过 getServerSession 拿会话,拿到就直接附带在 ctx 上,拿不到就抛错。
// src/server/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; export const createContext = async () => { const session = await getServerSession(authOptions); return { session }; }; const t = initTRPC.context<typeof createContext>().create(); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); });这段代码是 t3code 里权限控制的地基。有了它,业务代码里做"登录后可见"的接口,只需要把publicProcedure替换成protectedProcedure,其他什么都不用改。我见过很多项目在 service 里手动判断if (!ctx.user) throw 401,写几百个接口就得写几百遍,而 procedure 级别的拦截让这件事只发生一次。
需要说明的是,保护 procedure 只是服务端的第一道门,前端页面本身的跳转控制还要靠 Next.js 的 middleware 或服务端组件判断 session 做重定向,两者配合才能形成完整的"未登录看不到页面、请求进不了后台"的双重防线。
4. 数据模型与业务落地:Prisma Schema 设计和迁移流程
4.1 数据模型设计的一个核心原则:Schema 即文档
t3code 里所有数据库模型都定义在prisma/schema.prisma里。这个文件在项目里的地位相当于"权威数据字典",谁的字段叫什么名字、什么类型、关联哪张表,全看这里。我强烈建议的规则是:任何一张表的设计都要让字段的 name 本身就具备自解释性,不要在字段注释里写"这里存的是用户输入的价格",而是直接把字段名写成effectivePrice这种让前端直接使用的命名。
举一个具体的设计案例。假设要做一个订单模块,我一般建议拆成 Order、OrderItem、Product 三张表。OrderItem 存下单时的商品快照,包含productId、title、unitPrice、quantity、snapshotJson,其中snapshotJson专门存下单那一刻的商品图片和规格参数,防止商品信息后续修改导致历史订单显示错乱。这个设计几乎适用于所有电商类业务。
model Order { id String @id @default(cuid()) userId String status String @default("PENDING") // PENDING PAID SHIPPED COMPLETED CANCELLED totalAmount Decimal @db.Decimal(10, 2) items OrderItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) } model OrderItem { id String @id @default(cuid()) orderId String productId String title String unitPrice Decimal @db.Decimal(10, 2) quantity Int snapshotJson Json? order Order @relation(fields: [orderId], references: [id]) }Decimal 存金额是我一直坚持的点,原因很简单:JavaScript 的 Float 计算金额会出现 0.1 + 0.2 = 0.30000000000000004 的问题,订单金额字段绝不是用 Float 的类型。这个教训在 t3code 早期版本里踩过,后来统一改用 Decimal,再也不需要对账对不上了。
4.2 迁移流程与开发体验:改表结构不会吓得不敢动手
Prisma 的迁移流程在 t3code 里被固定为三步走。第一步,改 schema.prisma;第二步,执行npx prisma migrate dev --name 本次改动描述;第三步,运行npx prisma generate重新生成客户端类型。之所以强调"固定三步走",是因为我有段时间偷懒直接用db push,开发时确实很快,但改动一多 schema 和 migration 历史就对不上了,后来在测试环境部署时差点酿成生产事故。
每个项目的迁移文件我都会要求纳入代码审查范围,因为数据库表结构变更属于高风险变更,改字段名时我建议同时给出migrate.sql里对应的DROP COLUMN或ALTER COLUMN语句,方便 DBA 或者队友在正式环境执行前先人工过一遍。
4.3 类型生成与前端代码的联动:改一个字段全链路同步
Prisma 类型生成是 t3code 类型安全链条的最底层。npx prisma generate会生成完整的 Prisma Client 类型定义,这些类型被 service 层引用,service 的返回类型推断出 tRPC 的输出类型,tRPC 的输出类型又被前端 hooks 消费。这意味着你改一个 model 字段,保存 schema 跑一遍 generate,从数据库到页面的整个类型链就同步完了,完全不需要手动去改 API 文档或前端 interface,这一点实际省下来的时间非常可观。
但这里有一个体验上的坑:Next.js dev server 不会监听 schema.prisma 的变更,所以跑完prisma generate后要记得重启 dev server 或者等待热更新重载,否则编辑器显示的类型还是旧的,会误导排查方向。
5. 工程化配套:环境变量、代码规范、测试与调试
5.1 环境变量管理:一份配置只在它该在的地方生效
t3code 里环境变量的使用规范用一句话总结:本地开发用.env,明文提交到仓库的示例用.env.example,生产环境用部署平台的环境变量面板,密钥严禁提交。这是一个老生常谈但又永远有人犯的错。我见过不止一次有人把DATABASE_URL直接写在next.config.js里然后推到 GitHub,虽然项目是私有的也架不住大意。
t3code 在环境变量处理上做了一个改进:所有环境变量集中在一个src/env.ts文件里,使用 Zod 定义每个变量的类型和必填校验,应用启动时如果缺少某个必要的变量会直接报错,而不是运行一半才崩溃。
// src/env.ts import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().default("http://localhost:3000"), UPLOAD_DIR: z.string().optional().default("./uploads"), }); export const env = envSchema.parse(process.env);这个文件的价值在于"fail fast"。曾经有一次部署新环境时我忘了配NEXTAUTH_SECRET,由于是必填项,应用直接拒绝启动,而不是等到用户登录时才发现问题。这种设计对自动化部署特别友好,配置不齐就起不来,问题暴露在 CD 阶段而不是用户触点。
5.2 代码规范与提交检查:让团队协作时不用互相纠正风格
代码规范这块,t3code 锁定的是 ESLint + Prettier + TypeScript strict 模式。TypeScript 的 tsconfig 里我坚持开了全部严格选项,包括noUncheckedIndexedAccess。有人觉得这个配置太啰嗦,访问数组元素还要处理 undefined,但实际开发中它逼你提前考虑边界情况,json 解析、数组取值、记录访问这些最容易出现空指针的地方都被类型拦截了一轮。
提交前检查用的是 husky + lint-staged,只对暂存区内文件做检查。也就是说你提交代码之前,它只会检查你这次改动的文件,不会整仓扫描耽误时间。我在模板里还顺手加了一条提交信息规范,格式是feat(模块): 描述/fix(模块): 描述,配合项目页面的提交历史看起来非常清晰。
5.3 调试手段:server logs 与浏览器网络面板两手抓
t3code 项目跑起来之后的调试方式,我通常分两层。服务端逻辑的分支排查靠日志,tRPC 的每个 query 和 mutation 我都会在 service 关键路径上放console.log或者接上 pino 日志库,输出入参和中间计算结果。前端侧的调用链路排查靠浏览器 DevTools,因为 tRPC 是 HTTP 协议,Network 面板里能看到完整的请求和响应体,配合 React Query DevTools 查看缓存状态,大部分数据问题几分钟就能定位。
6. 上线与部署:t3code 在生产环境的跑法
6.1 构建与静态导出:哪些功能可以开,哪些必须关
t3code 默认部署目标是 Node.js 环境而不是静态托管,因为 tRPC、NextAuth、Prisma 都依赖服务端运行。在next.config.ts里,我做了几项固定配置:输出模式保持默认的 Node 服务模式,不开启output: export;图片用next/image的远程图片需要在域名白名单里加上实际使用的图片 CDN;缓存策略上,页面路由优先用服务端渲染和增量静态再验证,API 路由不加任何export const dynamic = "force-static",因为 tRPC 接口本质是动态数据接口,强制静态会引入脏读。
在这里特别提一个部署时的常见坑:如果你忘了在服务器的环境变量里配置UPLOAD_DIR,服务端的文件写入功能会在运行时失败。因为 t3code 的文件上传逻辑用的是本地目录写入,而不同服务商的运行环境有的提供持久化磁盘,有的文件系统是临时性的,后者重启就丢文件。所以模板里默认把上传目录指向外部对象存储,本地目录只是 fallback。
6.2 镜像构建与容器部署:我踩过的镜像体积坑
t3code 提供了一套 Dockerfile 作为模板,核心是多阶段构建。第一阶段装依赖并执行prisma generate,第二阶段正式构建 Next.js 应用,第三阶段用精简的 Node 基础镜像跑产物。第一个版本我图省事直接用一个镜像跑了全流程,结果垂下来的镜像体积超过 1.5GB,推送、拉取都要等半天。后来把依赖安装和生产依赖分离之后,镜像体积压到了 200MB 以内,部署效率提升非常明显。
FROM node:20-alpine AS base WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder COPY . . COPY --from=deps /app/node_modules ./node_modules RUN npx prisma generate RUN pnpm build FROM base AS runner ENV NODE_ENV production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"]构建之后要用output: "standalone"模式产出独立目录,这样最终镜像里不需要带完整的 node_modules,runtime 阶段只要跑单独编译出来的服务文件。我把这个配置写进了 t3code 的默认next.config.ts,省得每次部署再改一遍。
6.3 CI/CD 流水线:从推代码到上线的全过程
t3code 的 CI 流程固定在 GitHub Actions 里,三个 job 串联。第一个 job 做代码检查,跑 lint、typecheck、单元测试;第二个 job 做 Docker 镜像构建并推送到私有镜像仓库,仅在 main 分支推送后触发;第三个 job 部署到服务器,通过 SSH 在目标机器上拉取最新镜像并重新创建容器。这套流程很常规,但有一个细节让我印象特别深刻:它能在新代码有问题时拦住发布,大大减少"人肉上线"带来的失误。
我特别建议流水线里加一个"PR 预览环境"步骤:当开发者向 main 提交 PR 时,自动化临时起一个预览环境,直接把 PR 分支部署上去,然后在 PR 评论里附上访问链接。这个功能对前后端联调非常有效,因为 t3code 项目本来就是前后端一体,非技术同事想看看效果也不用等部署完成,给链接就能看。
7. 落地过程中的常见问题与排查实录
7.1 类型定义冲突与 tRPC 类型递归问题
t3code 项目中我遇到过最隐蔽的类型问题是循环类型引用。具体场景是 User 包含多个 Order,Order 又包含关联的 User,tRPC 默认会序列化整个类型图,如果两个实体互相包含对方,类型推断会变得极其庞大甚至报出 "Type instantiation is excessively deep" 的错误。
解决方案是做 DTO(Data Transfer Object)层裁剪。在 service 层返回数据前,手动映射成扁平的 DTO 对象,只保留当前业务需要的字段。比如用户详情页,user字段里只保留id、name、avatar,不要直接返回整个 user 对象带上 Order 列表。这个习惯在 t3code 里被当作规范执行,也避免了服务端把多余数据传到前端造成的信息泄露。
7.2 Prisma 连接池与开发环境热更新冲突
这个问题很多用 Prisma 的 Next.js 项目都会遇到:开发环境改了代码热更新模块重新执行,Prisma Client 每次都会重新初始化实例,连接池被无限打开,几次改动之后数据库连接数飙升,最终数据库拒绝连接。
解决方法是把 Prisma Client 挂到 globalThis 上做单例缓存,确保开发环境始终复用同一个实例。这个方案在 Prisma 官方文档里有说明,但 t3code 把它写进了默认代码里,不依赖开发者每次自己记住。我见过很多人上线前一晚遇到这个问题然后急得满头大汗,根源上预防真的值得。
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }7.3 tRPC 错误信息在客户端"消失"了
t3code 早期版本里,服务端抛出的TRPCError默认只传 error code 给客户端,具体 message 不会透传。这导致排查问题很不方便。后来在trpc.createTRPCClient里配置了 error formatter,把服务端的 message 内容返回给客户端,开发环境下前端可以直接弹出真实报错信息,生产环境再按 code 做统一提示。这个细节需要提前配置,别等到项目上线后为"前端只看到 Unexpected token"这种提示而头疼。
7.4 环境变量配置不一致导致的"在我电脑上是好的"
最后想讲一个最朴素但影响最大的一件事:环境变量不一致。因为 t3code 的模板把环境变量定义得很集中,我有一次在两个开发环境之间切换时,一个环境的DATABASE_URL指向本地数据库,另一个指向测试环境数据库,结果本地运行的页面数据看起来正常,可测试环境就是"怎么改都不对"。排查了半天才发现问题出在.env.local和.env的文件优先级上。从此 t3code 规定所有环境配置文件只保留一份,.env.example作为模板提交到仓库,真正的.env文件不纳入版本控制,避免不同开发者的本地配置互相污染和覆盖。
8. 我最想提醒你的一句话
这套模板从第一个版本到现在,最大的收获不是代码本身,而是让我养成了一套固定习惯:新项目先定类型边界,再写逻辑;任何跨端数据都要求自动推导类型,而不是靠人肉同步;部署流程全部脚本化,能自动化绝不手动操作。使用 t3code 一段时间后,每次从零开始一个新服务,我都能快速度过"头疼期",把精力花在真正需要思考的业务逻辑上。
如果你打算在自己的项目里参考这套方案,我的建议很直接:不要一次性全盘照抄。你可以先在一个小型内部项目里把 tRPC 和 Prisma 这两环用起来,感受一下端到端类型安全到底解决的是哪些具体痛点;等你确认这种开发方式适合你的团队,再把目录规范、部署流程这些工程化的设计逐步吸收进去。工具和模板永远只是起点,真正让代码变得好维护的,是你在一次次项目里形成的取舍标准。