做t3code这个模板项目,纯粹是被"重复造轮子"恶心到了。每当要开一个新服务端渲染项目,我都要把Next.js、TypeScript、Tailwind、tRPC、Prisma这些组件手动拼一遍,少说也要折腾一个下午。更烦的是每次拼装方式还不一样——有时候忘了配eslint,有时候prisma的client没重新生成,有时候t3的type定义和服务端对不上。后来我干脆把整个过程沉淀成了一套自己的起始模板,取名t3code,把"TypeScript + Tailwind + tRPC + Prisma + Next.js"这条技术栈的初始化、目录划分、认证接入、代码规范全部固定下来。这篇文章不是官方文档复读,而是我在反复重建项目过程中攒下来的一套实操记录,适合那些已经用过Next.js但不想每次都在配置上耗时间的人。
1. 为什么非要从零拼技术栈,直接躺在模板上不好吗
很多人第一次听到t3code会问:"create-next-app不就能初始化项目吗?为什么非要自己再造一套模板?"说实话create-next-app确实能给你一个能跑的Next.js项目,但它给的是"白纸",不是"脚手架"。白纸意味着数据库、ORM、接口层、鉴权、数据校验、代码风格这些你都得自己往上叠,而叠的过程中最大的成本不是写出来,而是搞清楚它们之间怎么协作。
t3code的核心思路恰恰是把这套协作关系固化下来。它对我而言解决的三个实际问题:
- 新项目从"空目录"到"带认证、带数据库、带接口层、带样式系统、带lint和commit规范"只需要跑一次初始化命令,把耗时从半天缩到十分钟。
- 多个项目之间保持一致的目录结构和命名约定,后续维护、换人接手、甚至从A项目移植逻辑到B项目,成本都低很多。
- 把容易出错的配置部分(tRPC的服务端暴露、Prisma的schema同步、NextAuth的回调处理)用已经验证过的固定写法封装好,避免每次重踩一遍。
这套模板我自己用了大半年,从内部小工具到面向用户的产品页面都跑过,稳定性没出过幺蛾子。
1.1 t3code到底"封装"了什么
先把职责边界说清楚,t3code不是框架,它只是一个"起始模板仓库"。它做这几件事:
- 预装并配好Next.js(App Router模式)与React。
- 配好TailwindCSS,包括基础主题变量、深色模式切换、常用组件样式。
- 集成tRPC,分服务端路由和客户端调用两大部分,示例接口能直接跑通。
- 集成Prisma,预置好schema、初始化脚本、种子脚本,连接SQLite开箱即用(切换Postgres只需改一行)。
- 集成NextAuth,支持邮箱登录和GitHub OAuth两种方式,把session挂到tRPC上下文里。
- 配好ESLint、Prettier、Husky、lint-staged,保证提交代码前自动检查格式和类型。
它跟社区里那个T3 Stack的差别在于,我做了更激进的瘦身。社区版默认带了很多装饰性的demo页面、冗余依赖,而我删掉了花架子,只留下"真正上线要考虑的东西"。
1.2 团队协作视角下的模板价值
如果你只是一个人写全栈练手项目,模板有没有用其实无所谓。可一旦要跟别人协作,每个人都按自己的习惯创建目录、命名API、处理错误,项目很快就变成一锅粥。t3code相当于把团队约定写进了初始化代码里。新成员clone下来就知道:server/api/routers放接口,server/db放数据层,app/(routes)放页面,lib/放公共逻辑。这种约定不需要开会宣讲,看一遍目录结构就懂了。
2. 技术选型背后的逻辑:为什么是Next.js + tRPC + Prisma这个组合
网上关于技术选型的争吵已经够多了,我不想站队,只想说说我为什么把这个组合当成默认选项。核心原因就一条:类型安全能覆盖从前端到数据库的整条链路,且心智负担足够小。
2.1 Next.js选App Router而不是Pages Router
App Router刚出来的时候我持观望态度,直到服务端组件、Server Actions等特性稳定下来,才发现它对tRPC这类需要区分服务端/客户端边界的方案特别友好。App Router的目录约定让"哪些是页面、哪些是接口、哪些是服务端逻辑"变得一目了然。
需要强调一个容易踩的设计:如果用了Server Components,在组件里直接调用Node层逻辑是可以的,但要避免把server/目录下的逻辑塞进Client Components。我在t3code里用"use server"动作和tRPC查接口做了明确区分——页面骨架用服务端查询,交互性强的部分走tRPC客户端调用,这样既减少网络请求,又保留类型安全。
2.2 tRPC是"接口层"的最佳选择吗
如果项目已经决定前后端都用TypeScript,再用REST或GraphQL就等于凭空多维护一份接口文档和一堆类型定义。tRPC的价值不是跑得更快,而是让类型定义直接跨过网络边界——前端调trpc.user.getProfile拿到的是Promise<Profile>,而不是手动写死的any。
我把tRPC的使用方式固定成三种场景:
| 场景 | 推荐用法 | 理由 |
|---|---|---|
| 数据展示类查询 | tRPC的query | 自动缓存、类型安全,React Query缓存逻辑直接复用 |
| 表单提交、状态修改 | tRPC的mutation | 天然支持乐观更新、失败回滚 |
| 服务端组件里取初试数据 | 直接调server层的函数(绕过tRPC) | 减少一次HTTP往返,且不会触发序列化问题 |
没有tRPC的时候,前端和后端定义的接口经常出现字段名不一致的情况,改字段要两边同步。tRPC至少把这个损失降到了零。
2.3 Prisma在数据层的位置
Prisma被诟病最多的是性能,尤其在一些复杂关联查询上比手写SQL慢。但这个短板对绝大多数产品型项目根本不致命,真正关键的是它把数据库表结构变成了可审查、可迁移的类型定义源。t3code里用的Prisma有几个固定动作:prisma generate生成client,prisma migrate dev同步表结构,prisma db seed灌初始数据。
相比用TypeORM或Drizzle,Prisma的schema语法更容易读:
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] } model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) }这种定义方式对新手友好,对老手也够用。它把"数据库长什么样"这个核心事实摆在最显眼的位置,比在迁移文件里翻来翻去清晰得多。
2.4 Tailwind和配套工具的取舍
TailwindCSS现在是个不需要过多介绍的选项。t3code里我额外做了三件事:一是把主题变量抽成了CSS变量,方便动态换肤;二是配了prettier-plugin-tailwindcss,自动排序类名;三是放了一套基础组件样式(按钮、输入框、卡片),避免每次新项目都从零写按钮。这些东西单独看不值钱,但组合在一起就是"开箱即用"的底气。
代码规范这块,很多模板会给你塞一大堆依赖,但真正跑起来有用的就几个:eslint+eslint-config-next管代码质量,prettier管格式,husky+lint-staged在commit前跑检查。t3code里我把它们全部配好,甚至把提交信息格式也加了约束,这样即便团队里有人不爱守规矩,工具会逼他守。
3. t3code实操:从空目录到能跑通的完整流程
下面这部分是要动手的。我默认你已经装了Node.js 18+、npm或pnpm、Git,其他东西都是模板自动装的。整体流程大概是:clone模板、安装依赖、配环境变量、初始化数据库、启动开发服务器,然后就能看到示例页面。
3.1 初始化项目与目录拆分
我的做法不是发布到npm再让人create,而是直接用degit从Git仓库拉取模板:
npx degit yourname/t3code my-app cd my-app pnpm install如果你喜欢用npm create体验,也可以把模板做成CLI,但对我个人来说degit最简单直接。
拉下来之后目录结构长这样:
src/ app/ (auth)/ # 登录、注册页面 (dashboard)/ # 主要业务页面 api/ # Next.js API路由(主要给回调用) layout.tsx page.tsx server/ api/ root.ts # tRPC根路由合并 trpc.ts # tRPC初始化、上下文定义 routers/ auth.ts post.ts user.ts db/ index.ts # Prisma client单例 auth.ts # NextAuth配置 lib/ utils.ts # 公共工具 validation.ts # zod校验 styles/ globals.css prisma/ schema.prisma seed.ts这个目录不是拍脑袋定的,它遵循了一个原则:app/下只放页面和路由对应组件,server/下放所有只在服务端跑的逻辑,lib/放前后端共用的工具和校验器。这样从目录上就杜绝了"服务端代码不小心泄露到客户端"的问题。
3.2 tRPC服务端路由配置
tRPC的核心是router。我在server/api/trpc.ts里定义了两个关键东西:createTRPCContext和createCallerFactory。
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { getServerAuthSession } from "@/server/auth"; import { db } from "@/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, headers: opts.headers, }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createCallerFactory = t.createCallerFactory; export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;之后在root.ts里把各个子路由合并成一个根路由:
import { createTRPCRouter } from "@/server/api/trpc"; import { postRouter } from "@/server/api/routers/post"; import { userRouter } from "@/server/api/routers/user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;注意这里的导出类型AppRouter非常重要,客户端要引用它来做类型推导。写的时候只要保证自己始终从server/api/root导出类型,前后端类型就不会断裂。
3.3 客户端API封装
客户端侧,t3code提供的是直接可用的api对象。它基于@tanstack/react-query和tRPC React Query集成:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@/server/api/root"; export const api = createTRPCReact<AppRouter>(); export function TRPCReactProvider(props: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => api.createClient({ links: [ httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, headers() { return { cookie: typeof window === "undefined" ? "" : document.cookie, }; }, }), ], }) ); return ( <api.Provider client={trpcClient} queryClient={queryClient}> {props.children} </api.Provider> ); }具体调用的时候,在组件里直接用:
const { data: posts, isLoading } = api.post.getAll.useQuery(); const createPost = api.post.create.useMutation();用起来就是这样简单。我第一次用tRPC的时候还担心要配置一堆类型体操,结果发现它把复杂度都吸收进框架内部了,应用层的代码反而非常干净。
3.4 Prisma数据层搭建
t3code默认使用SQLite,是为了让用户clone下来什么数据库都不用装就能跑起来。对本地开发和生产首发来说,SQLite足够用。等流量真的大了,再切到Postgres只改datasource和连接串:
datasource db { provider = "sqlite" // 生产环境改成 "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" }环境变量文件.env里默认写的是:
DATABASE_URL="file:./db.sqlite" NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXTAUTH_SECRET="change-me-in-production" NEXTAUTH_URL="http://localhost:3000"初始化动作依次执行:
pnpm prisma generate pnpm prisma migrate dev --name init pnpm prisma db seed这里有个重要细节:prisma generate生成客户端代码之后,Node进程不会自动重载,所以每改一次schema,都要重新gen一次并且重启dev server。t3code在package.json里放了一个postinstall钩子,装完依赖后自动执行prisma generate,减少忘掉的可能。
3.5 认证流程接入
认证我选了NextAuth。虽然它也有一堆配置,但胜在社区大、文档全、兼容好。t3code的认证流程分两步:
第一步,配置server/auth.ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "@/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: "database" }, callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, });第二步,在tRPC上下文里把session注入进来,然后定义一个"需要登录才能访问"的保护Procedure:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这样在业务路由里写:
export const postRouter = createTRPCRouter({ 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, }, }); }), });用户未登录的时候调用post.create会自动得到401错误,前端统一的错误处理把它转成"请先登录"的提示。这个模式比在每个mutation里手动检查用户要省心太多。
3.6 代码规范与提交流程
最后是让提交时代码保持干净的配置。.husky/pre-commit里我写的是:
pnpm lint-stagedlint-staged配置:
{ "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,md}": ["prettier --write"] }这里有一个很多人忽视的细节:TypeScript的类型检查(tsc --noEmit)不要放进pre-commit里,否则项目大了以后每次提交都要等十秒以上,会让人抓狂。类型检查放到CI里去做,本地提交只做快速lint和format,这是我在真实项目里验证过的节奏。
4. 跑起来之后的坑:我遇到过的三个经典问题
模板归模板,实际跑起来一定会碰到环境差异带来的问题。下面这几个是我自己踩过、也在issue区经常看到的,一次性说透。
4.1 服务端组件里调用tRPC时的序列化问题
App Router的Server Components默认不能把Date、Map这类对象直接传给客户端组件,会报"Only plain objects can be passed to Client Components"。
tRPC默认返回的数据里经常包含Date(比如createdAt)。解决方案有两个:
- 用
superjson做transformer,t3code默认已经配了,但需要确保httpBatchLink那边也同步用了superjson。 - 在Server Component里直接调
createCaller而不是走HTTP链路,返回值仍然是Date,但传给Client之前手动序列化。
我实际验证下来,配合superjson后绝大多数情况都不用额外处理。唯一的坑是有些第三方库返回的对象不是普通Date而是dayjs对象,那就要在路由层先转换好再返回。
4.2 Prisma的client与Next.js的Fast Refresh冲突
开发模式下改了schema.prisma,重新generate之后按理说client会更新,但Next.js的Fast Refresh经常不买账,会继续用旧的内存缓存。结果是新字段明明建好了,写代码时类型报错、运行时报"Unknown argument"。
解决姿势很粗暴:改完schema之后,重启dev server。之后再把prisma generate的执行时机固定到predev脚本里,让自己少犯这个错:
{ "scripts": { "predev": "prisma generate", "dev": "next dev" } }4.3 NextAuth session在tRPC context里为空
这是我帮别人排查最多的问题。症状是前端已经登录了,但调用protectedProcedure仍然报UNAUTHORIZED。
原因通常不是NextAuth没登录,而是tRPC上下文创建时没有把session读进来。常见情况有:
server/auth.ts返回的auth()函数和NewNextAuth的调用不一致,导致session没有挂到ctx上。- 客户端
httpBatchLink的headers()里没有带cookie。服务端在同一个域名下可以靠cookie自动带上,但你如果用localhost和127.0.0.1两个域名访问,cookie是隔离的,就要特别注意。
我最推荐的一种定位方式:在createTRPCContext里打一行日志,先把session打印出来。如果session是null,就先检查浏览器Cookie;如果session有值,再检查context注入。这种一层层剥开的方式比瞎猜快得多。
5. t3code的扩展思路:别让模板绑架你的业务
t3code真正好用,是在你愿意把它当起点而非终点的时候。模板的价值在于提供默认值,但业务总有超出默认值的时候。我有几条扩展经验可以分享。
5.1 多数据源接入的正确姿势
如果项目需要同时访问Postgres和Redis,或者接入第三方API,不要把这些逻辑全部塞进server/db。我建议遵循一个原则:server/下的每个目录负责一种外部依赖,比如server/db管关系数据库,server/redis管缓存,server/github管外部API。tRPC路由层只做编排,不直接接触第三方SDK。这样将来想换缓存方案,只动server/redis这一个目录。
5.2 添加新业务模块的固定套路
我在t3code里总结出一个"加一个模块"的四步流程。比如要加一个"评论"功能:
- 在
prisma/schema.prisma里加Comment模型,执行prisma migrate dev。 - 在
server/api/routers/新建comment.ts,定义create、getByPostId等Procedure。 - 在
root.ts注册comment路由。 - 在页面组件里用
api.comment.create.useMutation()调用。
这四步做完,类型是自动同步的,不需要手动写任何接口文档。这套流程我重复了很多次,几乎没有出过错。
5.3 关于发布到生产环境的最后提醒
模板能让你快速开发,但上线前的几个硬指标不能省:
- 把
NEXTAUTH_SECRET换成长随机字符串,绝对不要用模板里的默认值。 - 数据库从SQLite切到Postgres时,记得改
DATABASE_URL,并重新执行prisma migrate deploy,而不是migrate dev。 - 静态资源走CDN,图片用next/image的远程优化配置,能省很多带宽。
- 写几个冒烟测试,至少覆盖"未登录访问受保护接口返回401"和"正常查询返回200"两个case。
说实话,做过多个全栈项目之后,我发现真正衬托开发效率的不是某个单一框架,而是"一套能闭眼用的初始化流程"。t3code对我来说就是这个流程的载体。如果你也在反复初始化Next.js项目,与其每次从零开始,不如花一个周末把自己的模板仓库搭起来,然后把约定、配置、坑全部固化在里面。前期投入一次,后面每个项目都省下实实在在的时间。