t3code:一套Next.js全栈项目起始模板的实操拆解 做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.jsApp 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 RouterApp 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拿到的是PromiseProfile而不是手动写死的any。我把tRPC的使用方式固定成三种场景场景推荐用法理由数据展示类查询tRPC的query自动缓存、类型安全React Query缓存逻辑直接复用表单提交、状态修改tRPC的mutation天然支持乐观更新、失败回滚服务端组件里取初试数据直接调server层的函数绕过tRPC减少一次HTTP往返且不会触发序列化问题没有tRPC的时候前端和后端定义的接口经常出现字段名不一致的情况改字段要两边同步。tRPC至少把这个损失降到了零。2.3 Prisma在数据层的位置Prisma被诟病最多的是性能尤其在一些复杂关联查询上比手写SQL慢。但这个短板对绝大多数产品型项目根本不致命真正关键的是它把数据库表结构变成了可审查、可迁移的类型定义源。t3code里用的Prisma有几个固定动作prisma generate生成clientprisma migrate dev同步表结构prisma db seed灌初始数据。相比用TypeORM或DrizzlePrisma的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自动排序类名三是放了一套基础组件样式按钮、输入框、卡片避免每次新项目都从零写按钮。这些东西单独看不值钱但组合在一起就是开箱即用的底气。代码规范这块很多模板会给你塞一大堆依赖但真正跑起来有用的就几个eslinteslint-config-next管代码质量prettier管格式huskylint-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.contexttypeof 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 createTRPCReactAppRouter(); 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_URLfile:./db.sqlite NEXT_PUBLIC_APP_URLhttp://localhost:3000 NEXTAUTH_SECRETchange-me-in-production NEXTAUTH_URLhttp://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.tsimport 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注入进来然后定义一个需要登录才能访问的保护Procedureexport 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做transformert3code默认已经配了但需要确保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项目与其每次从零开始不如花一个周末把自己的模板仓库搭起来然后把约定、配置、坑全部固化在里面。前期投入一次后面每个项目都省下实实在在的时间。