ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code:用T3 Stack构建类型安全的全栈Web应用模板

t3code:用T3 Stack构建类型安全的全栈Web应用模板

“t3code”这个名字,第一次看到的人多半以为是个编码竞赛题库或者某个刷题网站的代号。实际上,它是我维护了大半年的一套全栈Web应用启动模板:基于T3 Stack,把Next.js、TypeScript、TailwindCSS、tRPC、Prisma和NextAuth这六样东西整合成一条开箱即用的全栈流水线。这套东西最大的价值就一个字——稳。前端写什么类型,后端就跟着是什么类型,数据库那边也是同一套类型,整个请求链路几乎没有断裂点。适合谁呢?适合那些被REST接口双写类型折磨过、又不想一上来就上微服务的人;也适合想快速把想法落地成Web产品的独立开发者。如果你也受够了“前端定义一套类型、后端再定义一套、文档还经常过期”的日子,这篇东西值得你花十分钟看完。

1. 从“为什么”说起:t3code的选型逻辑

1.1 T3 Stack想解决什么问题

先解释一下T3 Stack的背景。这个缩写最初来自Theo的T3社区,代表一套固定的全栈组合:Next.js、TypeScript、TailwindCSS、tRPC、Prisma、NextAuth。它的内核不是某个花哨的框架,而是一套“类型安全优先”的工程理念。我在刚接触时也疑惑过:这不就是把几个流行库堆在一起吗?真用下来才意识到,T3更接近一套经过验证的默认约定,省掉了大量“选型吵架”的时间。

t3code这个模板遵循的正是这套约定。我见过太多团队在项目初期把时间耗在“用REST还是GraphQL”“用Redux还是Zustand”这类选择焦虑上,结果业务代码迟迟没法开工。t3code的思路不一样:直接把最常见、最稳妥的全栈方案锁死在一个模板里,让团队把精力留给真正有差异性的业务逻辑。它信奉的是boring technology——选那些成熟、文档多、社区大的工具,而不是为了技术新鲜感去冒险。

1.2 六个核心成员各司其职

  • Next.js:提供页面路由、服务端渲染、API路由和App Router的现代开发模式,是整个应用的外壳。
  • TypeScript:在编译期锁死类型错误,让“重构不慌”成为可能。
  • TailwindCSS:用原子类快速搭界面,不写一堆重复的CSS文件。
  • tRPC:替代REST/GraphQL,让前后端直接共享类型定义,API调用像调用本地函数一样自然。
  • Prisma:数据库ORM层,用schema文件声明模型和关系,生成类型安全的客户端。
  • NextAuth:接管登录和会话,支持Google、GitHub、邮箱密码等多种认证方式。

这六个成员不是简单拼凑,而是相互咬合。TypeScript是地基,tRPC和Prisma都围绕它生成类型;Next.js负责把前后端揉在一个项目里;NextAuth的存在又让受保护的路由和tRPC鉴权能无缝衔接。少了任意一环,这套链路的丝滑感都会打折。

1.3 为什么不用REST或GraphQL

很多人会问:既然有现成的REST和GraphQL,为什么还要用tRPC?我的回答是,tRPC解决的是“前后端契约维护成本”的问题。传统REST下,前端要手写axios请求、手工维护响应类型,后端每改一次字段,前端就要跟着改一遍;GraphQL虽然解决了过度获取,但引入了GraphQL Schema、Resolver、缓存策略等一整套复杂概念。

tRPC的思路完全不同:它把后端路由直接暴露成类型安全的过程调用,前端import一个hooks方法,就能获得完整的参数和返回值类型提示。类型定义只有一份,以TypeScript类型源为基准,前后端永远同步。我做个简单的对比:

维度RESTGraphQLtRPC
类型同步手写或代码生成手写SDL与Resolver自动推导
学习成本低高中低
缓存方案HTTP缓存成熟需要额外库可用React Query
适合场景开放API/多客户端聚合复杂数据全栈同构项目

对t3code这种前后端同在一个仓库的全栈应用来说,tRPC是效率最高的选择。当然,如果未来要开放第三方API,你也可以在Next.js里同时保留Route Handler,t3code不排斥混用。

2. 项目初始化与目录骨架

2.1 用create-t3-app快速起步

初始化t3code一点都不复杂,官方脚手架已经封装好了。

pnpm create t3-app@latest t3code cd t3code pnpm install

运行时会让你勾选需要的模块。我的习惯是全选:NextAuth、Prisma、Tailwind、tRPC,再加一个Docker。尽早把数据库和认证框架放进工程化流程里,比后面再补要省力得多。这里有个小坑:不要跳过初始化中的ESLint和Prettier配置,后期统一代码风格会很麻烦。

脚手架默认会在.env里生成一堆示例变量,但不会自动填充真实值。你需要手动创建.env并维护以下内容:

DATABASE_URL="postgresql://user:password@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="a-very-long-random-string" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET可以用openssl rand -base64 32生成。如果部署到生产环境,记得换成新的随机值并妥善保管,否则登录会话会失效。

2.2 目录结构拆解

脚手架生成的目录结构看着复杂,但每一层都有明确职责:

t3code/ ├── prisma/ # Prisma schema 与迁移文件 │ └── schema.prisma ├── src/ │ ├── pages/api/ # Next.js API路由,tRPC和NextAuth入口 │ ├── server/ # 服务端代码(db、auth、tRPC Router) │ ├── components/ # 可复用UI组件 │ ├── styles/ # 全局样式 │ ├── types/ # 全局类型定义 │ └── utils/ # 工具函数 ├── public/ # 静态资源 ├── .env # 环境变量 └── package.json

其中最重要的目录是src/server,它承载了tRPC Router、Prisma Client和NextAuth的底层配置。刚上手时最容易犯的错误是把数据请求逻辑直接写在组件里,正确的做法是全部收敛到server/api/routers中的Router文件中,由tRPC统一暴露。

2.3 数据库模型设计:以常见业务为例

Prisma的魅力在于用一个schema文件把所有模型和关系描述清楚。下面是我在t3code里常用的一个简单社交场景模型,用于演示用户、文章和点赞的关系:

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] likes Like[] } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Like { id String @id @default(cuid()) userId String postId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade) @@unique([userId, postId]) }

这里有几个设计要点:

  • 主键用cuid()而非自增整数,避免生产环境下遍历ID泄露数据规模。
  • onDelete: Cascade保证删除用户时其文章和点赞记录一并清理。
  • Like模型用@@unique([userId, postId])约束,防止同一用户重复点赞。

写完schema后执行:

pnpm prisma migrate dev --name init pnpm prisma generate

2.4 环境变量与多环境管理

t3code在本地、预发布、生产环境需要不同的环境变量。我通常维护三个文件:.env.development、.env.staging、.env.production,并在启动命令里通过dotenv-cli指定加载哪个文件。

pnpm add -D dotenv-cli pnpm dev:stage -- dotenv -e .env.staging

这样可以避免在多个环境之间手动切换配置。数据库连接字符串尤其要小心:本地用localhost没问题,生产环境如果用云数据库,记得在连接串里加上SSL相关参数。

提示:任何密钥都不要写进代码仓库。提交前检查.gitignore是否已排除.env*文件。

3. 核心功能实现:从认证到数据操作

3.1 NextAuth接入与会话管理

t3code里NextAuth的默认配置位于src/server/auth.ts。基础设置包括Provider、Session策略和回调:

import { type DefaultSession, type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: "jwt", }, callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub!; } return session; }, }, };

这里我踩过一次坑:早期把session策略配成database,结果发现用Prisma存储session时会频繁写库,高并发场景下数据库压力很大。后来改回jwt策略,并发表现立刻好了很多。对于大多数业务系统,JWT足够安全,而且天然适合Serverless环境。

3.2 tRPC Router的组织方式

tRPC的精髓是“定义路由”即“定义API”。t3code默认把Router拆成独立的子路由,再汇总到一个根Router中:

import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "./post"; import { userRouter } from "./user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, });

子路由内部通过query定义读操作、mutation定义写操作。比如文章的发布和点赞:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, include: { author: true, likes: true }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), like: protectedProcedure .input(z.object({ postId: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.db.like.create({ data: { userId: ctx.session.user.id, postId: input.postId }, }); return { success: true }; }), });

注意protectedProcedure和publicProcedure的区分:公开接口用public,必须登录的用protected。这背后的鉴权逻辑在src/server/api/trpc.ts里统一注入,不需要在每个Router里手写校验,省掉了一大堆重复代码。

3.3 Prisma数据操作

Prisma Client是同步类型安全的最后一环。ctx.db在tRPC上下文中被注入,所有数据库操作都有编译期校验。比如上面postRouter中的findMany和create,当你写错字段名或传入不存在的关联关系时,IDE会立即标红,不用等运行时才炸。

我在实际项目中还会用Prisma的条件查询做“时间线分页”,避免一次性加载太多数据:

export const timelineRouter = createTRPCRouter({ byCursor: publicProcedure .input(z.object({ cursor: z.string().optional(), take: z.number().default(10) })) .query(async ({ ctx, input }) => { const items = await ctx.db.post.findMany({ take: input.take + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, }); let nextCursor: string | null = null; if (items.length > input.take) { const next = items.pop(); nextCursor = next!.id; } return { items, nextCursor }; }), });

这种基于游标的分页比skip分页在数据量大时稳定得多,不会因为新数据插入导致重复或遗漏。

3.4 端到端类型安全的验证

t3code最爽的时刻发生在写完后端Router、打开前端组件那一刻。比如在React组件里调用:

import { api } from "~/utils/api"; export function PostList() { const { data, isLoading } = api.post.list.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }

api.post.list这个hooks方法会自动从后端Router推导出返回值类型。哪怕后端把Post模型的字段从title改成name,前端代码会在编译期立即报错,提醒你先修改前端。这种体验用惯了之后真的回不去REST那种手写接口文档的方式。

4. 部署、性能与工程化

4.1 部署到Vercel的配置

Vercel和Next.js同源,是t3code最顺滑的部署目标。部署前需要准备:

  1. 在Vercel控制台创建项目并关联Git仓库。
  2. 在Project Settings的Environment Variables里填入所有环境变量。
  3. 执行数据库迁移。

Vercel的Serverless环境是无状态的,所以数据库迁移要单独跑。推荐在CI流程里加上:

pnpm prisma migrate deploy

我用的是GitHub Actions,在main分支合并时自动触发迁移和构建:

name: Deploy to Vercel on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v3 with: node-version: 18 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm prisma migrate deploy - run: pnpm build - uses: vercel/action@v22 with: vercel-token: ${{ secrets.VERCEL_TOKEN }}

这里有个需要注意的点:vercel-token是长期有效的令牌,务必放到GitHub Secrets里,不要写在代码中。构建过程中的环境变量也可以直接从.env.production读取,但必须确保该文件在部署服务器上存在。

4.2 Docker自托管方案

如果不想绑定Vercel,t3code也支持Docker自托管。下面是我常用的生产镜像配置:

FROM node:18-alpine AS base RUN corepack enable WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS build COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm prisma generate RUN pnpm build FROM base AS runner ENV NODE_ENV=production COPY --from=build /app/.next ./.next COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/public ./public COPY --from=build /app/package.json ./ EXPOSE 3000 CMD ["pnpm", "start"]

再用docker-compose把应用和PostgreSQL绑在一起:

services: db: image: postgres:16 environment: POSTGRES_USER: user POSTGRES_PASSWORD: password POSTGRES_DB: t3code volumes: - pgdata:/var/lib/postgresql/data app: build: . ports: - "3000:3000" depends_on: - db environment: DATABASE_URL: postgresql://user:password@db:5432/t3code?schema=public NEXTAUTH_SECRET: production-secret

用Docker跑生产环境时,记得在宿主机上开放必要端口,并配置好反向代理和HTTPS证书。自托管的优点是灵活,缺点是需要自己运维,适合有一定服务器经验的人。

4.3 性能优化与缓存策略

t3code默认把页面组件分为服务端组件和客户端组件。凡是能不依赖客户端交互的,我都尽量放在Server Component里,减少JS体积。比如文章列表页,直接服务端读取数据并渲染HTML:

import { prisma } from "~/server/db"; export default async function PostListPage() { const posts = await prisma.post.findMany({ where: { published: true }, take: 20, }); return ( <ul> {posts.map((post) => ( <li>{post.title}</li> ))} </ul> ); }

对于需要频繁更新的接口(比如点赞数),再用tRPC的useQuery加React Query缓存,设置合理的staleTime。我的经验是:列表类数据staleTime设为30秒,详情类设为1分钟,既能保证数据新鲜,又不会频繁打数据库。不要对所有查询都用gcTime: Infinity,那会让内存占用飙高。

Prisma层面还有一个容易被忽略的性能点:select只取需要的字段。很多新人在findMany里不加select,结果把整行所有字段拉到内存。数据表字段多时,这个差异非常明显。

4.4 代码质量与CI落地

t3code自带ESLint和Prettier,CI阶段可以再加一层类型检查:

- run: pnpm lint - run: pnpm typecheck

在提交代码前,我习惯用husky和lint-staged在git commit时自动跑lint:

pnpm add -D husky lint-staged pnpm exec husky init

package.json里配置:

{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"] } }

这些工程化配置看着不起眼,但在多人协作时能省下大量review口水战。t3code的价值不只是跑通业务,而是从一开始就把质量和规范嵌进开发流。

5. 常见问题与排查记录

5.1 NextAuth报“MissingSecret”

这个错误几乎每个跑t3code的人都会遇到。原因是NEXTAUTH_SECRET没有正确设置。排查顺序:

  1. 检查.env是否存在且被正确加载。
  2. 确认next-auth版本是否与Next.js兼容。
  3. 本地调试时,可以在authOptions里临时传入secret参数,但记得生产环境一定要用环境变量。

如果用了Vercel部署,还需要额外检查Environment Variables是否填入了对应的Key和Value。很多人在本地正常、部署后登录失败,80%都是环境变量没同步。

5.2 tRPC返回“Unexpected end of JSON input”

这个报错通常出现在POST请求体过大或请求被代理截断时。处理方式分两步:

  • 在next.config.js中设置大请求体限制:
export default { experimental: { serverActions: { bodySizeLimit: "4mb" }, }, };
  • 如果你配置了反向代理(Nginx或Caddy),检查client_max_body_size是否足够大。

我在做内容管理系统时上传过几MB的Markdown文档,默认限制只有1MB,踩过这个坑后现在都会提前把请求体上限调高。

5.3 Prisma客户端热重载导致“Cannot use import statement outside a module”

这个问题多见于开发环境,原因是你把prisma实例定义在了一个会被Vite/Next.js热更新反复执行的模块里。t3code官方代码会把globalThis.prisma缓存住,避免每次热重载都创建新的PrismaClient。我遇到过一种非典型情况:在Router文件里直接执行了顶层new PrismaClient(),导致模块被多个引用重复加载。解决方法很简单:永远从~/server/db里统一导入db实例,不要在业务代码里自己new。

5.4 部署后页面空白但接口正常

如果t3code在本地一切正常,部署后首页空白,优先检查浏览器控制台报错。最常见的是:

  • Next.js版本与Node运行时不兼容(比如Node 16跑Next 14会有问题)。
  • 某些服务端函数使用了浏览器端API,例如window、localStorage,导致SSR时崩溃。

修复方式是尽量把客户端专属逻辑放到useEffect里,或者使用dynamic导入设置ssr: false。另外也要检查是否正确配置了output: "standalone",Docker部署时容易漏掉。

5.5 常见问题速查表

问题常见原因解决思路
登录回调404环境变量AUTH_URL或NEXTAUTH_URL配置错误统一改为正确的站点域名
数据库连接超时云数据库白名单或SSL未开启在DATABASE_URL中追加?sslmode=require
生产环境字体样式丢失未把Tailwind构建产物上传确认public目录被复制到部署产物
tRPC mutation返回403使用了publicProcedure但需要登录改成protectedProcedure
Prisma migrate失败schema存在外键冲突或类型不匹配先跑prisma validate再迁移
服务端内存持续上涨PrismaClient不断重建或React Query缓存过多统一db导入,控制gcTime

5.6 我的几个独家排障小技巧

遇到问题先看日志方向:本地跑pnpm dev时终端会直接输出报错堆栈,线上则要接日志服务。我常用pino配合Vercel Logs做结构化日志,把tRPC每个请求耗时和状态码都打印出来。这样定位慢查询和参数异常会快得多。

还有一个习惯:每次改完Prisma schema,条件反射式地跑pnpm prisma generate。很多人改了模型却忘了重新生成客户端,结果代码里还在用旧字段,这种错误找半天都找不到。t3code的CI里可以加一步检查prisma generate后git status是否有变化,确保迁移和生成结果都被提交。

数据库操作上,我也建议多利用Prisma Studio做数据可视化排查,命令是pnpm prisma studio。它可以直观地查看记录、修改字段,调试一些小数据问题时比命令行SQL方便很多。

用了大半年t3code,我最深的感受是:真正提升效率的不是某个单一的黑科技,而是整套工具链把“类型安全”从后端一路贯穿到前端,让本来需要大脑手动维护的契约关系完全自动化。这种体验一旦习惯,就很难回到以前那种“前后端各自为政、文档靠自觉、Bug靠猜”的开发模式。如果你也想搭一套自己的全栈底座,t3code这个模板值得直接拿来用,然后在上面长出自己的业务。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进