news 2026/10/26 23:32:08

T3 Stack实战:用Next.js+tRPC+Prisma搭建全栈项目骨架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
T3 Stack实战:用Next.js+tRPC+Prisma搭建全栈项目骨架

t3code是我最近一直在维护的一个全栈项目代号,说直白点,就是用T3 Stack搭起来的一套应用骨架兼代码资产库。去年接手这套东西的时候,团队里的前端、接口、数据库脚本还散落在好几个仓库里,每次加个功能要同时改三四处,光联调就能耗掉半天。后来我把技术栈统一换成Next.js + TypeScript + tRPC + Prisma这一套,所有代码收敛到一个仓库里,前后端类型全部打通,开发效率提升得非常明显。如果你也在做全栈项目的框架选型,或者正在纠结要不要上tRPC,甚至只是想知道一个“看着很酷”的技术组合在真实项目里到底怎么落地,这篇文章应该能给你说清楚。

1. t3code是什么:用一套技术栈把所有代码收进同一个仓库

1.1 我最初为什么放弃微服务,回到单体仓库

先说个背景。t3code刚开始并不是一个“设计出来”的项目,而是被逼出来的。团队早期做内部系统时,按“前后端分离”的惯用套路拆成了两个仓库,前端用Vue,后端用Node + Express,数据库脚本单独放在第三个仓库里。理论上挺好的,实际跑起来全是麻烦事:前端要的字段后端没给,后端改了字段名前端不知道,文档永远处于“刚写完就过期”的状态。最难受的是新人入职,光是把三个仓库的运行环境跑起来就得花一上午。

后来我决定把项目全部收敛到一个仓库里,采用Next.js做全栈框架。这个决定看起来像是“倒退”——毕竟大家都在拆服务,但对我们这种几十个页面、几十张表、十几个开发者的内部项目来说,单体仓库才是最合理的形态。代码就在同一个目录里,改后端模型和改前端页面可以发生在同一个PR里,Code Review的时候能一次性看清改动全貌,不用在两个仓库之间来回切换验证。t3code这个名字,说白了就是“T3栈的代码资产”,既是这个项目的代号,也是我后续所有内部项目的起手模板。

1.2 为什么选T3 Stack:类型安全到底省了多少钱

T3 Stack说的是Next.js + TypeScript + tRPC + Prisma + Tailwind这套组合,名字来自Theo的T3 for a Reason。其他技术都好理解,核心争议在tRPC:它把服务端路由变成函数,前端直接调用,而不是走REST URL。

我选择它的最大理由只有一个:端到端类型安全。用REST的时候,前端拿到的是一个any类型的响应体,需要自己写interface去猜后端返回什么结构,猜错了运行时才报错。用tRPC的时候,服务端定义的query和mutation就是函数签名,前端导入后自动获得完整的类型推导,参数、返回值、错误信息全部能在编辑器里看到。改后端一个字段,前端编译直接报错,根本不会出现“联调时才知道字段名抄错了”的尴尬。

拿生活里的例子说,REST像是你在餐厅点菜只能看纸质菜单,上菜后跟你要的不一样只能找服务员理论;tRPC则像是打开小程序点单,每个菜都带图片和价格,点错了当场就提示。少了这层“猜测”成本,开发体验提升是实打实的。

1.3 t3code适合谁用,解决什么具体问题

这套项目模板最适合三类人:一是做内部管理系统、后台工具的开发团队,这类项目功能重复度高、页面多,端到端类型安全能极大减少低级错误;二是想学习全栈开发的新人,整个链路清晰,不需要分别部署前端和后端,跑一个命令就能看到完整页面;三是需要快速产出MVP的个人开发者,t3code把所有基建都配好了,可以直接从业务代码开始写。

它解决的问题也都很具体:类型割裂带来的联调成本、接口文档维护不及时导致的前后端扯皮、新项目初始化时还得重新搭一堆脚手架等等。投一次资,后面所有项目都受益。

2. 项目结构设计:让新人凭直觉就能找到代码

2.1 目录划分与职责边界

t3code的目录结构我调整过好几版,最终定型如下,个人觉得是目前最清晰的一套:

t3code/ ├── prisma/ │ └── schema.prisma ├── public/ ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── user.ts │ │ │ └── root.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ ├── env.mjs │ └── styles/ ├── app/ │ ├── page.tsx │ ├── layout.tsx │ └── api/trpc/[trpc]/route.ts ├── .env.example ├── package.json └── tsconfig.json

这里有个关键约定:src/server目录下的代码绝不能被前端组件直接引用,前端只能通过src/trpc/client.ts暴露的hooks来调用。这个约定一开始不强制,后来有人为了图省事直接import了server端的db,把数据库连接串泄露到浏览器端产物里,我才意识到边界规则必须写进README里。所有数据库访问、鉴权逻辑、业务核心算法都放server目录,所有页面和组件都只访问tRPC的客户端代理,这样安全性和可维护性都有了保障。

2.2 数据模型设计:先想清楚关系再写代码

Prisma是t3code的数据访问层,我用它来定义数据库表和关系。项目初期最关键的步骤不是写页面,而是把prisma/schema.prisma里的模型定义好。我举个例子,这是一个典型的博客场景:

model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String slug String @unique content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime @default(now()) } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

这里几个细节值得说道。主键用cuid()而不是自增id,原因是分布式环境下生成id不需要查数据库,且cuid不会暴露数据量。所有关联字段都要显式声明外键列,比如authorId,不能只写关系对象,否则后续做原生查询时字段名很容易搞混。多对多关系用隐式中间表,Prisma会自己维护,我从不手动建联接表,除非中间表本身需要额外字段。

模型定义完后运行npx prisma migrate dev生成迁移文件,本地数据库自动同步结构。这个命令建议每次都写清楚迁移名称,比如npx prisma migrate dev --name add_post_tags,这样git历史里每个迁移的意图一目了然,回滚的时候也知道哪个对应哪个。

2.3 模型与路由的映射关系

t3code在路由组织上遵循一个简单原则:一个业务域对应一个router,一个router对应一个或几个Prisma模型。比如post.ts只管文章相关的查询和写入,user.ts只管用户相关逻辑。这个映射关系简单到新人看一眼就能理解,也是我刻意保持的风格——项目大了以后,最怕的就是那种“什么都能干”的巨型service类,找代码全靠搜索。

router内部的procedure命名也有一套约定:能查多个用list,查单个用byId或bySlug,写操作用动词开头比如create、update、delete。命名一致之后,前端调用方的变量名几乎可以由路由名直接推导,比如trpc.post.list.useQuery()对应返回的posts数组,完全不需要额外的文档说明。

3. 核心模块开发与实操记录

3.1 tRPC路由:从0到1搭一个接口

按t3code的惯例,我们看一个最小可用的postRouter怎么写:

import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, include: { author: true }, }); }), bySlug: publicProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { slug: input.slug }, include: { author: true, tags: true }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().optional(), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, slug: input.slug, authorId: ctx.session.user.id, }, }); }), });

三个细节值得展开。第一,publicProcedure和protectedProcedure的区别:前者是所有人可访问的查询,后者会先执行ctx.session的校验,没有登录直接抛异常。这个鉴权能力来自NextAuth,配置在src/server/auth.ts里。

第二,所有输入都用Zod做运行时校验。别小看这一步,TypeScript的类型检查只存在于编译期,运行时用户传过来的数据永远不可信。Zod同时承担了“类型定义”和“校验逻辑”两份工作,一个schema前后共用,让前端调用方拿到的类型和实际校验规则完全一致。

第三,Mutation里必须从ctx.session.user拿当前用户ID,绝对不允许让前端把userID作为参数传进来。这个是我在项目里反复强调的安全底线,否则任何一个登录用户都可以冒充别人发文章。

3.2 前端页面接入:useQuery和useMutation

服务端路由定义好之后,前端的接入过程简单得让人上瘾。以文章列表页为例:

"use client"; import { trpc } from "~/trpc/client"; export default function PostList() { const { data: posts, isLoading, error } = trpc.post.list.useQuery(); const createPost = trpc.post.create.useMutation({ onSuccess: () => { // 自动刷新列表 utils.post.list.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; if (error) return <div>error.message</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.author.name}</p> </li> ))} </ul> ); }

这里有个概念叫“invalidate”,翻译成大白话就是“让这个查询失效并重新拉取”。传统REST里更新完数据之后,前端得手动调用列表接口,或者用全局状态管理去同步。tRPC把这一步简化成了utils.post.list.invalidate(),它知道列表数据依赖哪些查询,自动做精确刷新。

在进去实际项目之前,我一直担心tRPC是不是只适合小项目,因为它的调用方式看起来和“标准化REST接口”差异很大。但跑了一年多下来,外部客户需要开放接口的场景毕竟少数,内部系统的数据更新频率也没那么高,这种“函数式调用”带来的效率提升远远超过了“不够REST”的理论缺憾。

3.3 环境变量与开发流程

t3code的环境变量校验用的是Zod,配置写在src/env.mjs里:

import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url().optional(), // 其他业务变量 }); export const env = envSchema.parse(process.env);

这个做法很值得复制:应用启动时如果环境变量缺失或格式不对,会直接在启动阶段报错,而不是运行到某个页面时才蹦出“undefined is not a function”这种让人摸不着头脑的异常。.env.example里只保留变量名和注释,真正的密钥放在.env.local,确保不被提交到git。

本地开发流程就是一行命令:pnpm dev。数据库迁移、类型检查、ESLint都是独立命令,我建议把postinstall钩子加上prisma generate,这样任何人拿到代码后第一次install,Prisma Client就会自动生成,省去一个常见的新人坑。

4. 部署、鉴权与常见问题排查

4.1 部署方案的选择与实际配置

t3code的部署不复杂,但有几个细节容易踩坑。我实际用的是Node服务器部署方案:本地先执行pnpm build,产物是.next目录和server入口,用PM2守护进程跑起来。如果你习惯用容器部署,把pnpm build的输出复制到镜像里即可,数据库连接串通过环境变量注入。

有一个容易被忽略的点:Next.js的独立输出模式需要在next.config.mjs里加一行output: "standalone",这样构建产物会包含一个精简版的Node server,体积更小、启动更快,特别适合容器环境。没有这行配置的话,部署时需要把整个node_modules带上去,镜像体积夸张,启动也慢。

部署环境里初始化数据库的命令要分开跑:prisma migrate deploy只负责应用迁移,不执行seed;prisma db seed只在第一部署时跑一次。我用一个简单脚本把这两个步骤串起来,顺序固定,避免重复seed导致主键冲突。

4.2 鉴权接入NextAuth的细节

t3code的鉴权默认用NextAuth,Provider我优先选OAuth式的账号体系。核心配置在src/server/auth.ts里,这里最常出的问题就是Session里的用户信息不够用。

默认情况下,JWT里只包含sub、email等基础字段,但我们的数据库主键是cuid,业务代码需要拿到user.id。解决方案是在callbacks里把token中的sub映射成数据库用户记录,再塞进session:

callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user) session.user.id = token.id as string; return session; }, }

如果没有这段配置,前端拿到的session.user.id永远是undefined,然后所有关联查询全部失败,还很难排查。我最初踩这个坑时花了半天时间,后来把这段代码注释写得特别详细,每周都有同事来感谢我留下的注释。

4.3 常见报错速查表

下面这张表是我在项目群里被问得最多的问题汇总,建议直接收藏:

报错或现象根本原因解决方法
PrismaClient is not defined开发模式下Prisma Client被热更新多次实例化用globalThis缓存PrismaClient实例
服务端改了router,前端类型没变tRPC类型生成没刷新重启dev server,或执行tsc --noEmit排查
Zod校验报“Invalid input”前端传了多余或缺失字段看报错中的路径定位字段,避免用any绕过
登录后session里没有user.idNextAuth回调未配置token.id按4.2节的callbacks配置补上
查询返回N+1条SQL关联数据未使用include检查是否循环访问了posts.author之类的关系
部署后页面500环境变量缺失确认.env里的DATABASE_URL和NEXTAUTH_SECRET

4.4 一个真实调优案例

t3code里有个数据看板页面,初始版本直接在前端列表组件里逐个调用trpc.tag.byId去查每篇文章的标签,页面加载时发了大量请求,接口平均耗时从200ms飙到2秒多。后来我把查询改成一次include把tags带出来,N+1消失,耗时降到300ms以下。

这个案例没什么高超技巧,但值得强调的是Prisma的include很强大,但在列表场景里要注意返回体积。像文章列表这种只需要标题和作者的场景,我用select而不是include,只拿需要的字段,数据库传输量和JSON序列化耗时会小很多。优化这件事永远别等产品经理提,上线前把基本查询都检查一遍,选对select和include,收益即时可见。

5. 代码规范与协作提效

5.1 ESLint、Prettier与Git钩子

t3code在代码规范这块直接用了create-t3-app的默认配置,但我额外补了Prettier、Husky和lint-staged。Prettier专门负责格式化,和ESLint做静态检查分工明确,避免两套工具在“该不该加分号”这种问题上打架。

Husky的配置重点在pre-commit钩子,逻辑是:提交前只对暂存区文件跑lint和格式化,而不是全仓检查,这样速度飞快,不会让开发者因为commit太慢而偷偷绕过钩子。pre-push钩子再跑一遍全量tsc --noEmit,类型错误在推到远端之前就被拦下了。有一次我忘了配置pre-push,结果同事把带类型错误的代码推到CI,整条流水线红了大半天,从那以后我就把这两道关卡写进了新人必读文档。

5.2 Git提交规范与Code Review清单

t3code的git提交信息强制用Conventional Commits格式:feat、fix、docs、refactor、perf、test、chore。不要小看这个格式,配合lint-staged的commitlint校验,可以自动生成清晰的CHANGELOG,也能让git bisect定位问题时快得多。

Code Review时我的检查清单比较固定,每次过一遍能拦住大部分问题:

  • 新加的input有没有写Zod校验?没有就驳回。
  • 查询有没有无谓的N+1?相关的关联字段够不够用?
  • mutation里涉及的权限有没有校验到?用户能不能操作别人的数据?
  • 有没有往client组件里引server端代码?
  • 新表有没有建立必要的索引,尤其是unique约束和WHERE条件要用的字段?

这套清单的最大价值是“无需讨论,照章办事”。新人不用怕被review时挑刺,因为问题都是公开的、可预期的,改起来也很快。

5.3 文档策略:注释只写why,不写what

t3code的代码里注释不多,但每一条都很有价值。我定了个原则:注释只写“为什么这么做”,不写“这段代码做了什么”。比如路由里的回调配置,注释会写“NextAuth默认不把user.id放进session,需要在callbacks里手动映射”,而不是写“获取用户id”。后者代码本身已经说明了,前者才是同事真正想知道的信息。

对于复杂业务规则,我不建议写大段注释,不如把逻辑抽成命名清晰的功能函数,把规则写在函数名和参数名里。代码库本身就是文档,只有那些牵一发动全身的设计决策才需要额外说明。

6. 踩坑总结与个人体会

6.1 我建议你直接照抄的几个决定

如果让我重新搭一遍t3code,有几个决定我一定会原样保留。

第一是“前后端同仓库”。对于内部中后台项目,这真的是效率最优解,不要为了“架构好看”把简单问题复杂化。第二是“所有输入一律Zod校验”,这是防止数据污染系统的最廉价手段。第三是“server目录严格隔离”,既保安全又明确职责,任何想跨界的冲动都要立刻拦住。第四是“新路由先命名再实现”,先想清楚procedure的名字和参数,再动笔写逻辑,比边写边想更容易保持一致性。

6.2 接下来我打算怎么扩展

t3code目前还处在“好用”阶段,离“通用”还有距离。我下一步准备做三件事:一是把上传能力整合进去,目前文件上传还是单独的服务,体验割裂;二是加一层基于Redis的查询缓存,把一些高频只读接口的响应时间再压下去;三是把项目里的通用工具函数抽成独立的npm包,方便其他项目复用。

但扩展之前,我会先把项目的docker compose环境补完整,让新成员一个命令就能起一套包含数据库、本地鉴权服务、应用实例的完整开发环境。基建做扎实了,后面加功能才不会慌。

最后分享一个我个人的实操体会:代码资产这件事,真正拉开团队差距的从来不是某个技术“够不够新”,而是在每一次小改动里是否愿意顺手把规范、注释和自动化补上。t3code之所以能成为我后续所有项目的起手模板,不是因为T3 Stack本身有多玄妙,而是因为它让我在加新功能时几乎不用想“基建”——所有精力都能花在业务逻辑上。如果你也想搭一套自己的全栈骨架,我的建议很简单:选定一个组合后别急着写业务,先把目录、命名、校验、部署、钩子这些基础设施一次配齐,然后每条坑都记录成文档,三个月后你回头看,一定会感谢自己当时的投入。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 23:31:29

基于OpenCV与深度学习的智能读码系统开发实践

1. 项目概述与核心价值这个基于OpenCV的读码系统毕业设计项目&#xff0c;本质上是一个融合了计算机视觉、深度学习技术和GUI开发的综合性解决方案。我在实际开发中发现&#xff0c;这类系统在工业质检、物流分拣、零售结算等场景有着广泛的应用需求。通过PyQt构建的图形界面&a…

作者头像 李华
网站建设 2026/10/26 23:30:45

动态规划入门:01背包问题详解与实现

1. 动态规划与背包问题入门 动态规划&#xff08;Dynamic Programming&#xff09;作为算法设计中的核心思想&#xff0c;在解决最优化问题时展现出强大的威力。今天我们要聚焦的是动态规划中一个经典问题——背包问题&#xff0c;特别是其基础形态01背包问题。 我第一次接触背…

作者头像 李华
网站建设 2026/10/26 23:30:14

C++标准库函数为何需要std::前缀?解析命名空间冲突与工程实践

1. 为什么C标准库函数需要std::前缀&#xff1f;第一次接触C标准库时&#xff0c;很多开发者都会疑惑&#xff1a;为什么调用abs、max这些基础函数非要加上std::前缀&#xff1f;这看起来像是多余的语法负担。但经过十多年的工程实践&#xff0c;我深刻理解到这种设计背后的深层…

作者头像 李华
网站建设 2026/10/26 23:30:04

CLIP模型实战:跨模态学习与应用开发指南

1. CLIP模型概述&#xff1a;跨模态学习的革命性突破CLIP&#xff08;Contrastive Language-Image Pre-training&#xff09;是OpenAI在2021年提出的跨模态预训练模型&#xff0c;它彻底改变了计算机视觉与自然语言处理之间的交互方式。这个模型的核心理念是通过对比学习&#…

作者头像 李华
网站建设 2026/10/26 23:28:31

Agent-Reach:让大模型真正触达真实世界的工具调用架构

1. “会聊天”和“能办事”之间&#xff0c;隔着一层Reach先说个场景。你和AI助手说“帮我查一下昨天提交的那份合同审批到哪个节点了”&#xff0c;它回复了一段很礼貌的话&#xff1a;“好的&#xff0c;我理解你的需求。建议你登录OA系统&#xff0c;在审批中心查看流程进度…

作者头像 李华
网站建设 2026/10/26 23:28:14

text-to-CAD:从文字生成可制造参数化模型的技术本质

1. 什么是text-to-CAD&#xff1f;它不是“AI画图”&#xff0c;而是工程设计链路的底层重构text-to-CAD这个标题乍看像又一个AI绘图噱头&#xff0c;但如果你在机械设计、模具开发、工业制造或教育实训一线干过三年以上&#xff0c;就会立刻意识到&#xff1a;它根本不是MidJo…

作者头像 李华