0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

275コミットの54%がバグ修正だった——AIでスマホアプリを生成するサービスを個人開発して踏んだ全地雷

0
Posted at

認証ハング・OAuthループ・504・認証なしAPI——設計からセキュリティ監査まで、根本原因と修正コードを全部書きます。

TL;DR(忙しい人向け)

  • 作ったもの: 日本語で話しかけるとAIがスマホアプリ画面を自動生成するサービス「SparkAI」
  • 規模感: 開発2ヶ月・275コミット・そのうち 54%がバグ修正・デプロイは数え切れず
  • スタック: Next.js 16 App Router / Supabase / Anthropic Claude / Stripe / Vercel
  • 踏んだ地雷: Supabase認証の永久ローディング・Google OAuthループ・Vercel 504・SSRのwindow参照・認証なしAPIエンドポイント
  • この記事で得られること: 各バグの根本原因と完全な修正コード。同じスタックで開発している人の地雷回避マップとして使ってください

👤 個人開発の試行錯誤やSparkAIの進捗を X で実況中 → @naokaihatu


作ったもの

開発2ヶ月。総コミット275本。そのうち 147本(54%)がバグ修正。デプロイ回数は数えるのをやめました。

「動くものを早く出す」を優先してリリースしたら、ユーザーから数件のバグ報告が届き、気づけばコミット履歴の半分以上がfixで埋まっていました。この記事はその全記録です。


作ったのは SparkAIhttps://sparkai-app.jp)——「日本語で話しかけるとAIがスマホアプリ画面を自動生成する」Webサービスです。

「Figmaで画面を作り、コードに落とす往復が面倒すぎる」という自分自身の課題から作り始めました。チャットで指示するとAIが複数画面のアプリUIを生成し、React Native(Expo Snack)またはWebのコードとして書き出します。
スクリーンショット 2026-06-03 0.29.37.png

機能をひとことで言うと:

操作 結果
「ヘルスケアアプリ作って」とチャット 複数画面のUIが一発生成
「色を変えて」「画面を増やして」 会話しながら修正
「コードを出して」 React Native / Webコードに変換
「Expo Snackで確認したい」 ワンクリックで実機プレビュー

技術スタック

┌─────────────────────────────────────────────────────┐
│                    ブラウザ (Safari / Chrome)          │
│  Next.js 16 App Router                              │
│  ├── "use client"  Tailwind CSS + CSS Variables     │
│  ├── AuthContext   @supabase/ssr(ブラウザ用)         │
│  └── Studio UI     lucide-react / framer-motion     │
└────────────────────┬────────────────────────────────┘
                     │ HTTPS
┌────────────────────▼────────────────────────────────┐
│              Vercel(サーバーレス関数)                │
│  /api/chat           → Claude Sonnet 4.6            │
│  /api/generate-code  → Claude Sonnet 4.6            │
│  /api/create-snack   → Expo Snack API               │
│  /api/stripe/*       → Stripe                       │
│  /auth/callback      → Supabase PKCE コード交換      │
└──────┬──────────────────────────┬───────────────────┘
       │                          │
┌──────▼──────┐          ┌────────▼────────┐
│  Supabase   │          │  Anthropic API  │
│  Auth + DB  │          └─────────────────┘
│  RLS有効    │
└─────────────┘
カテゴリ 採用技術 選定理由
フレームワーク Next.js 16.2.4(App Router) SSR/RSCでサーバー側認証が書きやすい
認証・DB Supabase + @supabase/ssr v0.10.2 RLSでユーザーデータ分離が宣言的に書ける
AI Claude Sonnet 4.6 / Haiku 4.5 日本語UIの生成品質が高い
決済 Stripe Webhook + Checkout Sessionの信頼性
ホスティング Vercel Next.jsとの統合が優秀
スタイル Tailwind CSS + CSS Variables テーマカラーを動的に切り替えるため併用

スクリーンショット 2026-06-03 0.30.00.png


作るうえでこだわったこと

「AIでアプリ生成」は珍しくない。なぜ使ってもらえるか

AI生成ツールはすでに無数にあります。その中で使い続けてもらうには、何か一つ「これだけは負けない」という軸が必要だと思っていました。

SparkAI が選んだ軸は 「初心者が迷わず使えること」 です。

AIツールあるあるの「何を入力すればいいかわからない」「生成されたものをどう使えばいいかわからない」を極力なくすため、こういう設計にしました:

  • 入力欄にはプレースホルダーとして実際の入力例を表示する(「ヘルスケアアプリを作って。歩数と睡眠を記録できるように」)
  • 生成後はすぐプレビューが見える。コードを理解しなくてもUI確認ができる
  • 「次のステップ」バナーで「今何をする画面か」を常に案内する
  • エラーが出たら「再生成する」ボタンだけ出す。原因の説明はしない

「何ができるかわかる → 試せる → 動いた」 この3ステップを10秒以内に体験できることをゴールにしました。

もう一つこだわったのは プロジェクトとして保存・継続できること です。多くの生成ツールは「一回作って終わり」。SparkAIは作ったUIを保存し、続きから修正でき、最終的に共有・ギャラリー公開まで持っていける設計にしています。「作ったもの」ではなく「育てているもの」として使ってもらいたかったからです。

——と、設計面ではこだわり抜いたつもりでした。ところが本番にリリースした瞬間から、その理想は次々と現実のバグに殴られ続けることになります。ここからは、実際に踏んだ地雷を一つずつ解剖していきます。


地獄①:認証が永久ローディングのまま固まる

「ずっとグルグルしてます」

リリース翌日。ユーザーからこんなメッセージが来ました。

「ずっとローディングが止まらなくて、画面が出てこないです」

ブラウザのコンソールを確認すると:

[SparkAI] Auth initialization timed out after 5s — showing recovery UI

Supabaseに curl で叩いたら 150ms で返ってくる。でもブラウザでは5秒経っても認証が終わらない。何が起きているのか。

getSession() の中身を追う

@supabase/ssrcreateBrowserClient は、インスタンス生成と同時に initialize()非同期で自動実行します。getSession()onAuthStateChange も、この initializePromise が完了するまで待機します。

createBrowserClient()
    └── initialize() を非同期で開始(ここが問題の起点)
          └── _recoverAndRefresh()
                └── ストレージのセッション読み込み
                      └── トークンが期限切れ?
                            └── _callRefreshToken() → fetch() 発火
                                                       ↑
                                          Safariでここが無限ハングする

犯人は「過去ログイン済みユーザーの期限切れトークン」でした。initialize() がトークンのリフレッシュを試みると、Safari 特定環境でこの fetch が Abort も Reject もせずただ固まりますgetSession() はその完了を永遠に待ち続けます。

修正:3層の防御で「絶対に黒画面にしない」

Layer 1 — fetch自体に4秒のタイムアウトを設ける

// src/lib/supabase/client.ts
function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), 4000);
  return fetch(input, { ...init, signal: controller.signal })
    .finally(() => clearTimeout(id));
}

export function createClient() {
  return createBrowserClient(url, key, {
    global: typeof window !== "undefined" ? { fetch: timeoutFetch } : undefined,
    auth: { detectSessionInUrl: false }, // OAuthコード交換はサーバー側で行う
  });
}

Layer 2 — getSession() 全体を Promise.race でタイムアウト

Layer 1 だけでは不十分でした。AbortError が Supabase 内部の try-catch に飲み込まれ、getSession() の Promise が永遠に pending のままになるケースが残ったためです。

getSession() が3秒で解決しなければ stale トークンを削除して null で解決する」というラッパーを作りました:

// src/context/AuthContext.tsx

// sb-* プレフィックスのCookieとlocalStorageを全削除する関数
function clearSupabaseStorage() {
  try {
    Object.keys(localStorage).forEach(k => {
      if (k.startsWith("sb-")) localStorage.removeItem(k);
    });
  } catch { /* ignore */ }
  try {
    document.cookie.split(";").forEach(c => {
      const name = c.split("=")[0].trim();
      if (name.startsWith("sb-")) {
        document.cookie = `${name}=; path=/; max-age=0; SameSite=lax`;
      }
    });
  } catch { /* ignore */ }
}

// getSession() を3秒でタイムアウトさせる
const sessionRace = new Promise<Session | null>(resolve => {
  const id = setTimeout(() => {
    console.warn("[SparkAI] getSession() did not resolve in 3s — clearing stale session");
    clearSupabaseStorage(); // 期限切れトークンを削除
    resolve(null);          // null で解決 → ログインページへ
  }, 3000);

  supabase.auth.getSession()
    .then(({ data: { session } }) => { clearTimeout(id); resolve(session ?? null); })
    .catch(err => { console.error(err); clearTimeout(id); resolve(null); });
});

Layer 3 — 5秒のバックアップで「回復UI」を表示

onAuthStateChange もハングしていた場合の最終手段。このとき 絶対に黒画面にしない ことを最重要ルールとしました:

t=0s   ページ読み込み開始
t=0s   getSession() 起動 + 3秒タイマー開始
         ↓
t=3s   【タイムアウト】stale Cookie削除 → settle(null) → /login へリダイレクト
         ↓(まだ解決しないなら…)
t=5s   【バックアップ】回復UIを表示

        ┌────────────────────────────────────┐
        │  ⚠️ 読み込みが止まっています        │
        │                                    │
        │  [再読み込み]                        │
        │  [ログインし直す(セッションをリセット)]│
        │  [前回のプロジェクトを解除して開く]    │
        └────────────────────────────────────┘

地獄②:Google OAuthでログインするとログイン画面に戻ってくる

ループ地獄

修正①の直後に別の報告が来ました。

「Googleでログインを押したら、ちょっと読み込んでまたログイン画面に戻ってきます」

再現してみると確かにループしている。原因は2つ重なっていました

原因A — Route Handler でCookieがレスポンスに乗っていない

Next.js App Router には Cookie を操作する方法が2つあります:

方法 どこに書かれるか
cookies() from next/headers Next.js内部のCookieストア
response.cookies.set() レスポンスオブジェクト直接

この2つを混在させると、NextResponse.redirect() にCookieが含まれません。

// ❌ 旧コード(動かない)
const supabase = await createClient(); // 内部で cookies() を使う
await supabase.auth.exchangeCodeForSession(code); // ← Cookieがセットされるはずが…
return NextResponse.redirect(`${origin}/studio`);
//                            ↑ このレスポンスにCookieが乗っていない!

結果として起きていたこと:

ブラウザ ──► GET /auth/callback?code=xxx
                  ↓
             exchangeCodeForSession() ← Cookieがセットされるはずが…
                  ↓
             redirect(/studio) ← Cookieが乗っていない!
                  ↓
ブラウザ ──► GET /studio(Cookieなし)
                  ↓
             セッションなし → redirect(/login)
                  ↓
ブラウザ ──► またログイン画面 ← 永遠にここに戻ってくる

修正:response を先に作り、そこに直接Cookieをセット

// ✅ 修正後 (src/app/auth/callback/route.ts)
export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");

  // Vercelでは request.url が内部URLになる場合がある
  // x-forwarded-host で正しいパブリックドメインを取得する
  const forwardedHost = request.headers.get("x-forwarded-host");
  const baseUrl = forwardedHost ? `https://${forwardedHost}` : origin;

  // OAuthエラー(access_denied等)も適切にハンドリング
  if (!code) {
    const oauthError = searchParams.get("error");
    const desc = searchParams.get("error_description");
    if (oauthError) {
      return NextResponse.redirect(
        `${baseUrl}/login?error=${encodeURIComponent(desc ?? oauthError)}`
      );
    }
    return NextResponse.redirect(`${baseUrl}/studio`);
  }

  // ① response を先に作る
  const response = NextResponse.redirect(`${baseUrl}/studio`);

  // ② Cookie を response.cookies に直接書くクライアントを用意
  const supabase = createServerClient(url, key, {
    cookies: {
      getAll: () => request.cookies.getAll(),
      setAll: (toSet) => {
        toSet.forEach(({ name, value, options }) =>
          response.cookies.set(name, value, options) // response に直接!
        );
      },
    },
  });

  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) {
    return NextResponse.redirect(
      `${baseUrl}/login?error=${encodeURIComponent(error.message)}`
    );
  }

  // ③ Cookieが確実に乗った response を返す
  return response;
}

原因B — 古い期限切れCookieがOAuth後も残っていた

修正Aを入れても稀に戻ってくる。もう一段深いバグがありました。

ユーザーが /login にいるとき、地獄①の「3秒タイマー」がバックグラウンドで動いています。「Googleでログイン」を押すとブラウザがGoogleへ飛び、JavaScriptコンテキストごと破棄されるのでタイマーはキャンセルされます。でも stale Cookie はそのまま残ります。

t=0s   /login 読み込み
t=0s   getSession() 起動 + 3秒タイマー開始(staleトークンをリフレッシュ中…)
t=1s   ユーザーが「Googleでログイン」を押す
t=1s   ブラウザがGoogleへ遷移 → JSコンテキスト破棄 → タイマーキャンセル
       ↑ でも stale Cookie はまだ残ってる

t=3s   Google認証完了 → /auth/callback → 新セッションCookieをセット → /studio
t=3s   /studio でgetSession()起動
t=3s   stale Cookie を発見 → リフレッシュ試行 → ハング
t=6s   3秒タイマー発火 → clearSupabaseStorage() → 新しいCookieまで削除! → /login

新しいセッションが古いCookieに駆逐されていたわけです。

修正:Googleログイン前にstale CookieをクリアしてからOAauth開始

const signInWithGoogle = async () => {
  if (!supabase) return;
  // 同期的にsb-* Cookieを全削除してからOAuth開始
  // → OAuth後の新しいCookieがクリーンな状態で書き込まれる
  clearSupabaseStorage();
  await supabase.auth.signInWithOAuth({
    provider: "google",
    options: { redirectTo: `${window.location.origin}/auth/callback` },
  });
};

地獄③:コード生成とExpo Snack連携が504を返す

2本立ての504

「コードを生成する」「Expo Snackで開く」の両方が断続的に 504 Gateway Timeout を返していました。原因はそれぞれ別でした。

/api/generate-code — 2回試行が仇になった

// ❌ 旧コード
export const maxDuration = 60; // Vercelの関数タイムアウト

for (let attempt = 0; attempt < 2; attempt++) {
  const msg = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 12000,   // 大きすぎる
    messages: [{ role: "user", content: prompt }],
  });
  // 1回目でバリデーションエラーなら2回目を試みる
}

計算するとすぐわかりました:

Claude Sonnet 4.6 で 12,000トークン生成 ≈ 最大 40〜50秒 / 回
2回試行の最悪ケース = 80〜100秒 > maxDuration: 60秒 → 504

1回目でバリデーションエラーになっても、決定論的フォールバック(generateReactNativeCode())が存在するため、2回目の試行は不要でした。

// ✅ 修正後
export const maxDuration = 120; // 余裕を持たせる

// 1回試行のみ。失敗時は決定論的フォールバックを使う
const msg = await client.messages.create({
  model: planModel,
  max_tokens: Math.min(planMaxTokens, 8000), // 12000 → 8000
  messages: [{ role: "user", content: prompt }],
});

React Nativeの実装コードは通常 4,000〜6,000トークンで収まるため、8,000で十分です。

/api/create-snack — 外部fetchにタイムアウトなし

Expo Snack APIへの fetch に signal を渡し忘れていました:

// ❌ Expo APIが重くなるだけでVercelの60秒制限を踏む
res = await fetch("https://exp.host/--/api/v2/snack/save", {
  method: "POST",
  body: JSON.stringify(body),
  // signal がない
});

// ✅ 25秒でAbort
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25_000);
try {
  res = await fetch("https://exp.host/--/api/v2/snack/save", {
    method: "POST",
    body: JSON.stringify(body),
    signal: controller.signal,
  });
  clearTimeout(timeout);
} catch (e) {
  clearTimeout(timeout);
  const isTimeout = e instanceof Error && e.name === "AbortError";
  return Response.json({
    message: isTimeout
      ? "Expo Snackへの接続がタイムアウトしました"
      : "Expo Snackへの接続に失敗しました",
  }, { status: 502 });
}

外部APIへのfetchには必ずタイムアウトを設定する
AbortController を使わないと、外部サービスが重い・ダウンしているだけでVercelのサーバーレス関数が制限まで待ち続け、ユーザーには 504 が返ります。25〜30秒程度のタイムアウトを必ず設けること。


地獄④:ログインページにエラーが表示されない

OAuth失敗時(例:認証コード期限切れ)、コールバックルートはユーザーを /login?error=認証に失敗しました へリダイレクトします。このエラーをログインページで表示しようとしていたコードがこちら:

// ❌ 動かない
const [error, setError] = React.useState<string | null>(() => {
  if (typeof window === "undefined") return null; // SSR時はnull
  return new URLSearchParams(window.location.search).get("error");
  // ↑ クライアントで再実行されないため、常にnull
});

"use client" と書いたコンポーネントでも、Next.js App Router は初回レンダリングをサーバー側で実行しますuseState の初期化関数はサーバー側で呼ばれ(window が undefined なので null を返し)、その値が hydration 時にそのまま使われます。クライアントで再実行されることはありません。

"use client" ならクライアントだけで動く」——この思い込みが生んだバグです。

// ✅ useEffect で hydration 後に読み取る
const [error, setError] = React.useState<string | null>(null);

React.useEffect(() => {
  const urlError = new URLSearchParams(window.location.search).get("error");
  if (urlError) queueMicrotask(() => setError(urlError));
  // ↑ set-state-in-effect eslintルール対策でqueueMicrotaskを使用
}, []);

Next.js App Router の大事なルール
"use client" は「イベントハンドラや hooks を使う」という宣言であり、「SSRしない」という意味ではありません。window / document / localStorage は必ず useEffect 内で使うこと。


地獄⑤:セキュリティ監査で5件の問題が出た

リリース後、こんな質問をもらいました。

「ブラウザの検証ツールでコードとかデータが全部見えるんですが、セキュリティ的に大丈夫ですか?」

これをきっかけに全APIルート(19本)を監査しました。

まず「見えても問題ないもの」から整理する

ネットワークタブに見えるもの
├── Supabase URL / anon key ← 公開前提の設計。RLSが守る
├── Stripe 価格ID          ← 公開前提。課金はサーバー側で検証
├── 自分のプロジェクトデータ  ← 自分のデータ。RLSで他人のは見えない
└── /api/chat などのルート名  ← 全WebアプリのURL構造は見える

Supabase anon key が見えて問題ない理由: anon key は「未認証ユーザーとして接続する」ための公開鍵です。実際に読み書きできるデータは RLS(Row Level Security)ポリシー が決定します。anon key を知っていても、RLS が許可していないデータには触れません。

getUser() vs getSession() — サーバー側は必ず getUser() を使う

  • getSession() → Cookieのトークンをそのまま信頼する。Cookie改ざんに弱い
  • getUser() → Supabaseサーバーへ問い合わせてサーバー側で検証。改ざんを検知できる

Route Handler / Server Component では必ず getUser() を使うこと。

全APIルート認証チェック表

ルート 認証方法 状態
/api/chat getUser()
/api/generate-code getUser()
/api/create-snack getUser()
/api/import-code getUser()
/api/design-diagnosis getUser() ✅(修正済)
/api/gallery GET 認証不要 ✅ 公開ギャラリー
/api/gallery PATCH getUser()
/api/share GET 認証不要 ✅ 公開共有ページ
/api/share POST getUser()
/api/stripe/checkout getUser()
/api/stripe/webhook Stripe署名検証
その他Stripe系 getUser()

見つかった問題(5件)

🔴 HIGH:/api/design-diagnosis に認証が丸ごと抜けていた

恥ずかしながら、このルートだけ認証チェックを完全に忘れていました

このルートは Anthropic の Claude API を叩くため、認証なしで誰でも無制限にAPIクレジットを消費できる状態でした。発覚したとき少し血の気が引きました。

// ❌ 旧コード(認証チェックなし)
export async function POST(req: NextRequest) {
  const apiKey = process.env.ANTHROPIC_API_KEY;
  // ← いきなりClaudeを呼ぶ。誰でも呼べる。
}

// ✅ 修正後(認証 + レート制限)
export async function POST(req: NextRequest) {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

  const creditsResult = await consumeCredits(user.id, "design-diagnosis", 1, user.email);
  if (!creditsResult.allowed) {
    return NextResponse.json({ error: "rate_limit" }, { status: 429 });
  }
  // ここからClaude呼び出し
}

🟡 MEDIUM:NEXT_PUBLIC_ADMIN_EMAILS で管理者メールがJSバンドルに漏洩

// ❌ ブラウザのSources タブで管理者メールアドレスが丸見え
const ADMIN_EMAILS = (process.env.NEXT_PUBLIC_ADMIN_EMAILS ?? "").split(",");

NEXT_PUBLIC_ 付きの環境変数は ビルド時にJavaScriptバンドルに埋め込まれます。管理者確認専用のサーバーAPIを作り、クライアント側のメールリストを削除しました:

// src/app/api/admin/check/route.ts
export async function GET() {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return Response.json({ isAdmin: false });
  // サーバー側の環境変数のみで判定。クライアントには漏れない
  return Response.json({ isAdmin: isAdminUser(user.id, user.email) });
}

🟡 MEDIUM:Stripe Webhook の userId に誤フォールバック

// ❌ Stripeメタデータに user_id がない場合、project_id(UUID)が user_id カラムに入る
const userId = meta.user_id ?? overrides?.project_id; // バグ

// ✅ フォールバックを削除。user_id がなければ早期リターン(既存コードが正しく動く)
const userId = meta.user_id;

このまま放置すると、特定条件下でサブスクリプション情報が壊れる可能性がありました。

🟡 LOW:ai-guard.ts にユーザーIDとメールをハードコード

// ❌ GitHubが公開リポジトリなら世界から見える
const OWNER_IDS = new Set([
  "8f9d659c-db9d-4b0b-a26d-7cae5ffc060b", // メールアドレスまでコメントに書いてた
]);

OWNER_USER_IDOWNER_EMAIL 環境変数に移行し、ハードコードを完全に削除しました。


踏んだ地雷まとめ

技術的な教訓

バグ 根本原因 対策
認証永久ローディング @supabase/ssrgetSession() がSafariでハング Promise.race で3秒タイムアウト + stale Cookie削除
Google OAuthループ cookies()NextResponse.redirect() の混在でCookieが乗らない response.cookies.set() を使う
Google OAuthループ② OAuthボタン押下時にstale Cookieが残留 OAuth前に clearSupabaseStorage() を呼ぶ
504タイムアウト 2回試行 × 大きな max_tokens が Vercel 制限を超える 1回試行 + 適切な max_tokens + maxDuration を余裕を持たせる
504タイムアウト② 外部APIへのfetchにタイムアウトなし AbortController で必ずタイムアウトを設ける
エラー表示されない "use client" コンポーネントもSSRされるのを忘れた window / documentuseEffect の中で使う

セキュリティの教訓

問題 教訓
認証なしAPIエンドポイント ルート追加のたびに認証チェックを確認する習慣を持つ
NEXT_PUBLIC_ へのメール漏洩 NEXT_PUBLIC_ は「公開してよい値のみ」。メール・UUID・シークレットはNG
Stripe Webhookのロジックバグ 外部イベントを処理するコードはフォールバックロジックを慎重に書く
ハードコードされた個人情報 GitHubが公開状態ならコード内の個人情報は環境変数に移す
サーバー側で getSession() Route Handler では必ず getUser()(サーバー検証)を使う

そしてまた新しいバグが出る

この記事を書きながら気づいたのですが、これは終わらないです。

修正を入れて「よし、完璧」と思っても、数日後にまた別のバグが来ます。

バグ報告 → 原因調査 → 修正 → デプロイ → 「完璧」
                                          ↓
                              数日後また「〇〇が動きません」
                                          ↓
                              バグ報告 → 原因調査 → 修正 → ...(ループ)

コミット275本のうち147本(54%)が修正系。開発期間2ヶ月でこの比率です。最初はこのループが「自分の実力不足」に見えてつらかったです。でも続けていくうちに考え方が変わりました。

本番で動かして初めてわかることが、確実に存在する。

ローカルで完璧に動いていたコードでも、Safariと特定バージョンのSupabaseの組み合わせでハングする。Expo Snack APIが特定の時間帯に遅くなる。これは事前に全部防ぐのは現実的ではありません。

個人開発における「品質」とは「完璧なコードを書く」ことではなく、「何かが壊れたとき素早く直せる体制を作ること」 なんじゃないかと今は思っています。

そのために今やっていること:

  • コンソールに意味のあるログを仕込む[SparkAI] Auth initialization timed out のように、どこで何が起きたかが一目でわかる形で
  • エラーは「具体的なメッセージ」でユーザーに伝える — 「エラーが発生しました」ではなく「Expo Snackへの接続がタイムアウトしました。後ほど再試行してください。」
  • 絶対にUI が完全に死なない設計にする — 認証が壊れても「再読み込みボタン」だけは出る、というラストライン

おわりに

「動くものを早く出す」を優先した結果、認証バグ・タイムアウト・セキュリティホールが重なりました。でも、それぞれの問題を追いかけて根本原因を突き止めるプロセスで、@supabase/ssr の内部実装や Next.js App Router の SSR 挙動について、ドキュメントを読むだけでは得られない理解が深まりました。

バグは恥ずかしいけれど、全部メモして記事にすれば誰かの地雷回避マップになる——そう思って全部書きました。同じスタックで個人開発している方の参考になれば嬉しいです。


SparkAI — 日本語でスマホアプリを作れるサービス
https://sparkai-app.jp

質問・フィードバック・開発の進捗は X で発信しています → @naokaihatu
SparkAIの開発こぼれ話もそこで呟いています。


0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?