はじめに
Webデザイナー・フリーランス向けの請求書・見積書ツール Folio を個人開発してリリースした。
既存の請求書ツールは会計ソフトと一体化していて重すぎる、という課題感から作り始めた。「請求書だけ完璧にやる」というコンセプトのSaaSだ。
この記事では技術スタックの選定理由と、開発中にハマったポイントを共有する。
技術スタック
| カテゴリ | 採用技術 |
|---|---|
| フロントエンド | Next.js 15 (App Router) |
| 言語 | TypeScript |
| スタイリング | Tailwind CSS |
| バックエンド / DB | Supabase |
| 認証 | Supabase Auth |
| 決済 | Stripe |
| ホスティング | Vercel |
| PDF生成 | ブラウザのprint API |
なぜこのスタックにしたか
Next.js App Router
SEOと初期表示速度を重視した。LPはSSGで配信し、アプリ部分はクライアントサイドで動かす構成にした。
Supabase
- PostgreSQL + RLS(Row Level Security)でマルチテナントのデータ分離が簡単に実現できる
- 認証・DB・ストレージが一体化していて個人開発のコスト感に合う
-
@supabase/ssrを使うことでNext.jsとの統合もスムーズ
PDF生成
最初はpuppeteerやpdf-libを検討したが、ブラウザの window.print() で十分な品質が出ることがわかり採用した。サーバーレス環境での実行コストもゼロになる。
開発中にハマったポイント
SSGビルドで window is not defined が出る
これが一番ハマった。
@supabase/ssr の createBrowserClient() は初期化時に window.localStorage にアクセスする。そのため、モジュールスコープで呼び出すとSSGビルド(Node.js環境)でクラッシュする。
ダメなパターン:
// lib/db.ts
const supabase = createClient() // ← モジュール読み込み時に実行される → クラッシュ
export async function getInvoices() {
const { data } = await supabase.from('invoices').select('*')
// ...
}
正しいパターン:
// lib/db.ts
export async function getInvoices() {
const supabase = createClient() // ← 関数内で初期化することで回避
const { data } = await supabase.from('invoices').select('*')
// ...
}
コンポーネント内でも同様で、useState の初期化やコンポーネントボディでの呼び出しはNGで、useEffect やイベントハンドラの中で初期化する必要がある。
// ダメ
export default function LoginPage() {
const supabase = createClient() // ← コンポーネントボディはSSG時に実行される
const handleLogin = async () => { ... }
}
// OK
export default function LoginPage() {
const handleLogin = async () => {
const supabase = createClient() // ← ハンドラ内なら安全
await supabase.auth.signInWithPassword(...)
}
}
export const dynamic = 'force-dynamic' を付けてもSSG時のプリレンダリングは完全には止まらないので、根本的に上記のパターンで対処する必要がある。
RLSでのマルチテナント実装
Supabaseの強みはRLSでユーザーごとのデータ分離が宣言的に書けること。
-- 自分のデータのみ操作可能にするポリシー
CREATE POLICY "自分の請求書のみ"
ON invoices
FOR ALL
USING (auth.uid() = user_id);
これだけでSQLインジェクション耐性も含めてセキュアになる。アプリ側で WHERE user_id = ? を書き忘れるリスクがなくなるのが大きい。
源泉徴収の計算ロジック
日本特有の税制で、報酬額によって税率が変わる。
export function calcWithholding(base: number): number {
if (base <= 1_000_000) {
return Math.floor(base * 0.1021)
}
return Math.floor(1_000_000 * 0.1021 + (base - 1_000_000) * 0.2042)
}
100万円を超えた部分だけ20.42%になるという仕様で、最初は全額に20.42%を掛けるミスをしていた。フリーランスの方に実際に確認して修正した。
Stripeの実装
サブスクリプション(月額¥500)はStripe Checkoutで実装した。個人開発なら決済UIを自前で作るより、Stripeのホスト型UIに任せた方が工数・セキュリティの両面で圧倒的に楽だ。
Webhookで customer.subscription.updated などのイベントを受け取り、Supabaseの profiles テーブルに subscription_status を保存する構成にした。
まとめ
| 項目 | 内容 |
|---|---|
| 開発期間 | 約2ヶ月(副業) |
| コスト | Supabase Pro + Vercel Pro + Stripe(従量課金) |
| 難しかった点 | SSGとSupabase Clientの相性、源泉徴収の計算仕様 |
| 良かった点 | Supabase RLSでセキュリティが楽、PDF生成をブラウザに任せたこと |
個人開発でSaaSを作る場合、Next.js + Supabase + Vercelの組み合わせは開発速度・コスト・スケーラビリティのバランスが良くておすすめだ。
よかったら使ってみてください → https://heyfolio.app