1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

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/ssrcreateBrowserClient() は初期化時に 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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?