自分のプロジェクトの最初のコミットは "Initial commit" という名前で、そこにはもう認証、データベース、決済、i18n、管理画面が入っています。二つめはよく "remove useless things" という名前になります。この二つのコミットの組み合わせが、自分の働き方をいちばんよく要約しています。ゼロから始めるのではなく、テンプレートをクローンして削るところから始めるのです。
そういうプロジェクトが十もあれば、何が核で何がノイズかについてかなり鋭い判断が得られます。意見ではありません。履歴に残っていますし、同じ削除が繰り返される場所に表れています。
こう始めることで得られるもの
運送会社向けの障害管理 SaaS は、ゼロから本番まで三日でした。一日目、クローンして余分を外す。二日目、障害のスキーマ、画像アップロード、詳細のドロワー、一覧のキャッシュ。三日目、ランディング。三日とも丸ごとドメインの作業でした。誰もログインには触れていません。
画像生成の SaaS も同じ始まり方でした。"Initial commit" の日にはすでに生成画像のスキーマ、S3 のヘルパー、ブランディングとテーマがありました。翌日には生成のパイプラインができていました。認証、Drizzle を使った Postgres、Stripe、i18n は最初から入っていました。
節約できるのはログインを書く手間ではありません。それが引き連れてくる一か月ぶんの小さな決定です。セッションはどこに住むのか、新しいルートをどう守るのか、Cookie が切れたら何が起きるのか、言語をどう宣言するのか。
いつも残るもの
- 入り口がひとつの認証。 各ページが呼び出すのを覚えておかなければならない
requireSession()関数ではなく、プライベートなツリーに置くlayout.tsxです。新しいページは、チェックのコピーを覚えているかどうかではなく、どこに置いたかで守られます。 - 型付き ORM とバージョン管理されたマイグレーションを備えた Postgres。 ここには気づくのに時間のかかる細部があります。drizzle のスナップショットがずれると、
drizzle-kit generateは空のデータベースに対して差分を取り、スキーマ全体のCREATE TABLEを再発行します。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 が悪いからではありません。アプリがすでにサーバーから管理者用の接続で全アクセスをしていたので、ポリシーは一度も評価されず、存在しない安心感だけを与えていたからです。二層ある認可のうち片方しか生きていない状態は、一層だけよりも悪い。どちらが効いているのか誰にもわからないからです。
そして肥大した管理画面。チームのテンプレートは数か月ぶんの管理画面とユーザー管理を溜め込み、最後はそれを丸ごと消すコミットに至りました。子プロジェクト全部にコストを継承させるのに、使う子はほとんどいません。
遅れてくるコスト
テンプレートの上で働くことについて、誰も語らないのがこれです。親のバグは、すでに全部の子に複製された状態で生まれます。
いちばん高くついたのは 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、サイトマップ、Stripe の checkout の同一オリジン検証がぶら下がっていました。どのクローンでも静かに全部間違っていて、コンソールにはエラーひとつ出ません。
言語の判定は acceptLanguage.includes(locale) でした。クライアントの優先順位を無視し、部分文字列の一致で当ててしまいます。"sl-SI" は "sl" を含むからです。その一行を継承したプロジェクトの数だけ掛け算されます。
親テンプレートはどう学ぶか
修正は逆向きに流れます。子から親へ。あるプロジェクトが何かを見つけたら、その修正をテンプレートに上げ、次のプロジェクトはそれを持って生まれます。これが機能するのは、修正に「その不具合を再導入したら落ちるテスト」が添えられている場合だけです。でないと、次のリファクタがその規則をバグへと「単純化」して戻します。
自分のテンプレートに対する直近の大きな堅牢化は、39 項目の PR でした。processed_stripe_events テーブルによる Stripe webhook の冪等化、サーバーアクションでは identity を常にセッションから取り引数では受け取らないこと、フィードバックの各読み取りの WHERE に userId を入れること、checkout の戻り先 URL の同一オリジン検証。マイナス 11,421 行、プラス 7,268 行。消したものはほとんどが、もう存在しない構造を説明するチュートリアルでした。
そして次のプロジェクトは chore: inicializa desde la plantilla endurecida で始まりました。要はそこに尽きます。
テンプレートはタイプ量の節約ではなく、すでに学んだことを置いておく場所です。役に立つかどうかを決めるのは何が入っているかではなく、見つけた子にとどめずに修正を毎回そこへ上げる規律です。
