我项目的第一个提交叫 "Initial commit",而它已经带着认证、数据库、支付、i18n 和一个后台面板。第二个提交,很多时候叫 "remove useless things"。这一对提交比什么都更好地概括了我的工作方式:我不从零开始,我从克隆自己的模板然后往下砍开始。
十个这样的项目,能磨出一套相当锋利的判断,知道什么是内核、什么是噪音。这不是观点:它写在历史记录里,写在同一种删除反复出现的地方。
这样开头赚到了什么
一个给运输公司做的故障管理 SaaS,从零到生产用了三天。第一天:克隆,删掉多余的。第二天:故障模型、图片上传、详情抽屉、列表缓存。第三天:落地页。这三天全都花在领域上。没人碰过登录。
一个图像生成的 SaaS 也一样。在 "Initial commit" 那天,生成图像的模型、S3 helper、品牌和主题就已经就位了。第二天生成流水线就跑起来了。认证、带 Drizzle 的 Postgres、Stripe 和 i18n 都是现成的。
省下的不是写登录本身。而是它拖着的那一个月的小决定:会话住在哪里、新路由怎么保护、cookie 过期时会发生什么、语言怎么声明。
总会留下来的东西
- 只有一道门的认证。 不是一个每个页面都得记得调用的
requireSession()函数,而是私有分支里的一个layout.tsx。新页面靠你把它放在哪里而受保护,不靠你记得复制那段检查。 - 带类型 ORM 和版本化迁移的 Postgres。 这里有个要花点时间才会发现的细节:如果 drizzle 的 snapshot 失去同步,
drizzle-kit generate会跟一个空库做 diff,把整个 schema 的CREATE TABLE重新发一遍。解决办法是重建初始 snapshot,直到generate重新说没有变更。 - 从第零分钟就有 i18n。 事后再加,就得动项目里的每一个字符串。事先加,花一个下午。
- 一个最小的后台。 用户列表和四个指标。仅此而已。
总会被砍掉的东西
Stripe 是最明显的例子。在一个打卡应用里,checkout、portal 和 webhook 路由在初始提交的同一天被整个删掉:338 行出局。在一个内部查看器模板里,一模一样。两个不同的项目,相隔一年,同一次删除。
结论不是把 Stripe 从模板里拿掉,而是把它放到一个开关后面:
export const USE_STRIPE = process.env.USE_STRIPE === "true";
export async function POST(request: NextRequest) {
if (!USE_STRIPE) {
return NextResponse.json({ error: "Stripe is not enabled" }, { status: 501 });
}
不需要支付的项目现在设 USE_STRIPE=false 然后继续。有一个花了点时间才发现的细节:开关关闭时 Stripe 客户端是 null as unknown as Stripe,所以第一次不小心使用会抛出一个含糊的 TypeError,而不是说清楚发生了什么。现在它是一个会抛出明确错误的 Proxy。
另一处砍掉的是 RLS。这些表出生时都带着 drizzle-orm/neon 的 crudPolicy({ role: authenticatedRole, read: authUid(table.userId) })。全都被移除了。原因不是 RLS 不好:而是这个应用所有访问都走服务端的一个 admin 连接,所以策略从来没被求值过,只提供了一种并不存在的安全感。两层授权里只有一层活着,比只有一层更糟,因为没人知道哪一层说了算。
还有过度膨胀的后台。团队的模板攒了好几个月的后台面板和用户管理,最后落到一个把它整个删掉的提交里。它把成本传给了所有子项目,而几乎没有一个用得上。
延后支付的代价
这是基于模板工作时没人说的事:母模板里的一个 bug,一出生就已经被克隆到所有子项目里了。
我遇到过最贵的是 root layout 里的一行:alternates.canonical: BASE_URL。任何没有覆盖自己 canonical 的页面都会把自己 canonical 到首页,然后掉出索引。它在那儿待了好几个月,在每一个克隆里。我从中得到的规则是:缺少 canonical 是无害的,错误的 canonical 不是。从那以后共享 layout 不设 canonical,每条路由设自己的。
同类的还有两个:
.gitignore只忽略了.env.local。 克隆后最自然的第一步是cp .env.example .env。第一个提交就把密钥带走了。BASE_URL硬编码成了模板的域名。metadataBase、canonical、hreflang、OG 的 URL、sitemap 和 Stripe checkout 的同源校验全挂在它下面。在任何克隆里全都悄悄地错,而控制台里连一个错误都没有。
语言检测是 acceptLanguage.includes(locale):忽略客户端的偏好顺序,靠子串匹配蒙对,因为 "sl-SI" 里包含 "sl"。再乘上所有继承了这一行的项目。
母模板怎么学习
修复的方向是反过来的:从子到母。当某个项目发现了什么,修正就上传到模板,下一个项目一出生就带着它。这只有在修正配着一个"重新引入该缺陷就会失败"的测试时才管用,否则下一次重构会把这条规则"简化"回那个 bug。
我模板最近一次大规模加固是一个 39 点的 PR:用一张 processed_stripe_events 表实现 Stripe webhook 的幂等、身份永远来自会话而绝不作为 server action 的参数、每次读取 feedback 的 WHERE 里都带 userId、checkout 返回 URL 的同源校验。删了 11421 行,加了 7268 行。删掉的几乎全是描述着一个已不存在的结构的教程。
而下一个项目的开头是 chore: inicializa desde la plantilla endurecida。这就是全部的意义所在。
模板不是省打字,它是一个存放你已经学到的东西的地方。决定它有没有用的不是它自带什么,而是你有没有纪律把每一个修复都传回去,而不是把它留在发现它的那个子项目里。
