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?

合気道家向けサービスの個人開発の軌跡<設計〜実装編>

0
Last updated at Posted at 2026-04-29

はじめに

当記事はClaudeCodeと共同で執筆しました。
ただし内容については100%投稿主の意図したものであり、隅々まで監修しております。

前編の 合気道家向けサービスの個人開発の軌跡<題材選定〜要件定義編> では、「なぜこのアプリを作ろうと思ったのか」「要件定義をどのように進めていったのか」を書きました。続編にあたる本記事は、そこから先の設計〜実装フェーズで実際にぶつかった課題そこから学んだことについて書き残しておこうと思います。

対象読者

  • 個人開発をこれから始める、もしくは既に個人開発をやっている人
  • AIを活用して Webアプリやネイティブアプリを開発したい / している人
  • 合気道、もしくは合気道に関する当個人開発について少しでも興味をもってくれた人

前提(AikiNoteってどんなサービス?)

本題へ入る前に、今回開発した AikiNote について数行で要約しておきます。

  • 合気道に特化したデジタル稽古日誌アプリ
    • 技名などのタグによって稽古記録や検索・管理がしやすい
    • お気に入り数が他ユーザーには見えない合気道らしい非競争的なSNS機能も
    • (現在はWeb版のみ提供。ネイティブアプリ版も近日中にリリース予定)

どうやって進めていったか

AIの活用

AIツールとしては、最初は OpenAI の Codex を使っていましたが、Claude の Opus 4.6 が使えるようになったあたりで Claude に切り替えました。Claude Code の開発体験が非常に良いこと、出力される日本語の細かいニュアンス等が自然であることに魅力を感じて愛用しています。(Max プラン課金済み)

ドライバーはAI、ナビゲータは自分

巷で流行っている「バイブコーディング」のように、AIが書いたコードをよく読まずにとにかく動けばいい、という進め方はしたくありませんでした。今回の個人開発には継続的な学習という目的もありましたし、自分の手元に残る理解を増やしたい、という気持ちが強くありました。

よって、実際にコードを書くドライバーはAIに任せつつ、書かれたコードは基本的にナビゲーターである自分が読んでなるべく理解しながら指示を出す、というスタイルで進めました。

Plan モードは必ず通す

具体的な進め方としては、いきなり実装させずに、まず Planモードで 10〜15 分かけて具体的かつ正確なプロンプトを書いて投げ、AIと数回ラリーしてプランニングを精緻化してから、ようやく実装フェーズに入ってもらう、という流れに落ち着きました。バグ修正のときも同じです。「何が原因と考えているか」「修正は他のどの箇所に影響しうるか」「最小の変更で直すならどう書くか」を先にプランで詰めてから、実装に入ってもらうようにしました。

~/.claude/settings.json の defaultMode: "plan" を指定することで、セッション開始時に自動で Planモードに切り替わるのでオススメです!

AIを活用しつつコーディングを進める中で、「機能実装やコード修正の作業自体は AI にやってもらうにしても、最終的なコード品質の責任を持つのは自分」ということはずっと意識するようにしていました。可能な範囲でAIによって生成されたコードはちゃんと目を通すようにしないと技術的負債が蓄積されてしまい、あとで苦労することになるので気をつけましょう。

最終的な全体アーキテクチャ

技術スタック

ざっくり描くと、構成はこうなりました。

[ Web ブラウザ / iOS / Android ]
            │
            ▼
[ Vercel:  Next.js 16 + tRPC ]   ← BFF。サーバー側で Hono を fetch
            │
            ▼
[ Cloudflare Workers:  Hono API ]
            │
            ▼
[ Supabase (PostgreSQL + Auth + RLS) ]

別経路:
  メディア:  AWS S3 + CloudFront
  決済:      Stripe(Web)/ RevenueCat(Native)
  通知:      Expo Push Service → Hono → APNs/FCM

Web 版とネイティブ版で見比べると、レイヤーごとに採用したものはこのような形です。

レイヤー Web 版 ネイティブ版
フレームワーク Next.js 16(App Router, RSC) Expo SDK 54 + Expo Router 6
言語 TypeScript 5(strict) TypeScript 5(strict)
状態管理 Zustand + TanStack Query TanStack Query(永続化)
認証 Supabase Auth(Cookie) Supabase Auth(PKCE)
バリデーション Zod Zod
スタイル CSS Modules StyleSheet
国際化 next-intl(ja / en) (Web 版を WebView 経由)
決済 Stripe Checkout RevenueCat IAP
通知 (表示は Web 版) Expo Notifications
パッケージ管理 pnpm(monorepo) pnpm(独立リポ)
リント Biome Biome
テスト Vitest + Testing Library + happy-dom (ほぼWebviewなので手動テスト)
インフラ Vercel / Cloudflare Workers EAS Build / EAS Submit

なぜこの構成になったのか?

ここでは詳細には触れず、選んだ理由を一行ずつだけ。気になるトピックは次の「技術的な学び」で深掘りします。

  • なぜ Next.js + Hono を併用?
    • Next.jsは実務で最も使い慣れていたため
    • Hono は Node.js / Workers / Bun 等の複数ランタイムに乗るので、将来 API レイヤーの実行環境を別の場所へ寄せ直す柔軟性を残せるため
    • tRPC で BFF と Hono の境界の型を揃えれば、レイヤーを分けるコストを十分小さく抑えられるため
  • なぜ Supabase?
    • Auth・DB・RLSが一つに揃っていて、個人開発の規模で運用負荷・コストを抑えられるため
  • なぜ Expo?
    • 素の React Native を 1 日触ってみて、ビルドツールチェーンや iOS / Android 双方のネイティブ環境構築のオーバーヘッドが個人開発で吸収しきれない量だと判断したため
    • EAS Build / EAS Submit で CI / ストア提出まで一気通貫で行えるため
  • なぜ pnpm monorepo?
    • Web 版のフロントとバックの間で Zod スキーマと型を共有したかったため
    • 実務で pnpm を使い慣れていたため
  • なぜ CSS Modules?
    • 実務で使い慣れているのと、責務が分離できてシンプルなため
    • 3 段階のフォントサイズをユーザー設定でグローバルに切り替える要件があり、ユーティリティクラス中心の Tailwind CSS は相性が悪いと感じたため

技術的な学び

本番デプロイ後に顕在化した遅延の解消

困ったこと

ある日、本番デプロイ直後の画面を眺めながら、認証直後の遷移が体感で明らかに遅いことに気付きました。ローカル開発では特に違和感がなかったのに、本番では待たされる感覚が強い。「Supabase 自体が遅いのでは?」と最初は疑いましたが、ダッシュボードを見ても DB のレスポンスタイムには問題がなさそうです。

その晩、半ばあきらめながら DevTools の Network パネルを開いてみたとき、目を疑いました。1 ページを表示するだけで、/auth/v1/user を含む同種の認証 API リクエストが何十回も、ほぼ並列に飛んでいたのです。各コンポーネントから無造作に useAuth を呼んでいた結果、認証状態の取得が「コンポーネントの数だけ」走っていました。本番経路では Vercel function と Supabase の往復遅延が乗るぶん、同じ並列リクエスト数でも累積の待ち時間が顕著に出ていたのだと思います。

採用したアプローチ

それぞれ役割の異なる 4 つの対策を多層で入れました。各対策が解決している課題は次の通りです。

  • AuthContext を 1 箇所に集約し、useAuth 経由で同じインスタンスを返す
    → 根本原因である「コンポーネントごとに認証取得が走る」N+1 状態を解消する本丸の対策。

  • getSession に 3 秒のタイムアウトをかぶせる
    → Supabase 側で予期せぬ遅延が起きた際にハングしないための、耐障害性の保険。

  • Server-Side Cache(Next.js 16 の unstable_cache + revalidateTag)
    → DB 往復回数そのものを削減し、認証後の遷移を体感で軽くする。

  • Next.js 16 の RSC リクエスト判定を proxy.ts で見て、プリフェッチ時には認証取得をスキップ
    → ユーザーが画面に到達する前に走るプリフェッチで、無駄な認証チェックを発生させない。

useAuth のタイムアウト機構はだいたい次のようなコードです。3 秒という数字は、Web Vitals の LCP「2.5 秒以下が Good」を一つの参考にしつつ、ユーザーが「画面が固まった」と感じてリロードを試みるまでの体感時間(経験則として 3〜5 秒)を踏まえて決めました。タイムアウト後は未認証として扱い、後段で再リトライする設計にしてあります。

// frontend/src/lib/hooks/useAuth.tsx (抜粋)
const fetchSessionWithTimeout = async () => {
  return Promise.race([
    supabase.auth.getSession(),
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error("getSession timeout")), 3000),
    ),
  ]);
};

const useAuthInternal = () => {
  const [session, setSession] = useState<Session | null>(null);
  useEffect(() => {
    fetchSessionWithTimeout()
      .then(({ data }) => setSession(data.session))
      .catch(() => {
        // タイムアウト時は未認証として扱い、後段で再リトライ
        setSession(null);
      });
  }, []);
  return session;
};

Next.js 16 のプリフェッチで毎回認証を走らせないために、proxy.ts で RSC リクエストを判別します。Next.js の RSC リクエストには RSC: 1 というヘッダが付いていることに気付いたので、判別に使っています。

// frontend/src/proxy.ts (抜粋)
export function shouldSkipAuth(req: Request) {
  // Next.js 16 の RSC リクエストには `RSC: 1` ヘッダが付く
  return req.headers.get("RSC") === "1";
}

最後に、Server-Side Cache 側はタグベースで無効化します。revalidateTag という関数の存在を、このタイミングで初めて知りました。

// frontend/src/lib/server/cache.ts (抜粋)
export const getProfileCached = (userId: string) =>
  unstable_cache(
    async () => fetchProfile(userId),
    ["profile", userId],
    { tags: [`profile:${userId}`], revalidate: 60 },
  )();

// 更新時:
await revalidateTag(`profile:${userId}`);

学び

4 つの対策を入れた結果、認証直後の遷移は本番でも違和感のない範囲まで戻りました。

学びとしては、フックの中で何が走っているのかを把握せず、雰囲気で useAuth を呼んでいたのが一番の根本原因だったと思っています。「ライブラリは便利な道具」だけでなく、「どこで通信が発生するか」をちゃんと自分の目で確かめる癖をつけなくてはいけないと痛感しました。

tRPC × Hono × Zod の型安全な開発体験

困ったこと

最初は愚直に Next.js の Route Handler(app/api/...)に全部書いていました。が、機能が増えるにつれて、フロントから API を呼ぶときの型を自分で書き写すのがしんどくなってきました。

採用したアプローチ

色々試した結果、最終的に落ち着いたのは「Next.js 上の tRPC procedures を BFF として用意し、その中身が fetch で Hono を呼ぶ」という構成です。Hono 側にも Zod スキーマを置いて、tRPC のバリデーションとは独立に再検証しています。最初は「同じことを 2 回書くの?」と思いましたが、Hono を将来モバイルアプリから直接叩く可能性も考えると、独立した API として作っておく方がよさそうだったので、こうしました。

まず BFF 側の fetch ラッパーを書きました。callHonoApi という名前で、URL を組み立てて fetch して、レスポンスを Zod で検証する、というだけのものです。

// frontend/src/server/trpc/hono.ts (抜粋)
export async function callHonoApi<T>(
  path: string,
  init: RequestInit & { schema: z.ZodType<T>; accessToken?: string },
): Promise<T> {
  const url = `${env.HONO_API_URL}${path}`;
  const res = await fetch(url, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...(init.accessToken && { Authorization: `Bearer ${init.accessToken}` }),
      ...init.headers,
    },
  }).catch((err) => {
    if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
      throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "API unreachable" });
    }
    throw err;
  });
  if (!res.ok) {
    throw new TRPCError({
      code: mapTRPCErrorCodeKeyFromStatusCode(res.status),
      message: await res.text(),
    });
  }
  return init.schema.parse(await res.json());
}

tRPC 側の procedure は、入力 Zod + callHonoApi の組み合わせで素直に書けます。

// frontend/src/server/trpc/procedures.ts (抜粋)
export const createPage = authenticatedProcedure
  .input(createPageSchema)
  .mutation(async ({ input, ctx }) => {
    return callHonoApi("/pages", {
      method: "POST",
      accessToken: ctx.session.access_token,
      body: JSON.stringify(input),
      schema: pageSchema,
    });
  });

Hono 側にも、独立した Zod スキーマと JWT 検証ミドルウェアを置きます。

// backend/src/middleware/auth.ts (抜粋)
export const requireAuth: MiddlewareHandler = async (c, next) => {
  const token = c.req.header("Authorization")?.replace("Bearer ", "");
  if (!token) return c.json({ error: "unauthorized" }, 401);
  const payload = await verifySupabaseJwt(token);
  c.set("userId", payload.sub);
  await next();
};

学び

改めて振り返ってみると、フロントとバックでそれぞれ Zod スキーマを書いている現状は、効率の面でだいぶ無駄が残っています。Web 版は pnpm monorepo として組まれているので、packages/schemas のような共通パッケージを切り出して、フロント側の tRPC procedure と Hono のルートバリデーションの両方から import する形にすれば、二重定義はそのまま解消できます。

「Hono が独立した API として動く」性質はランタイムでは保たれますし(import 元が共通になるだけで、リクエスト時のバリデーションは引き続き Hono 側で走る)、将来モバイルアプリから Hono を直接叩くときも同じ共通パッケージを参照すれば型・バリデーションの一貫性が取れて、独立性を維持したまま二重定義を解消できる、いいとこ取りの構成になります。となると、Zod スキーマを packages/schemas のような共通パッケージへ切り出して両プロジェクトから import する形に変えたほうが良いと思ったので、改善タスクとして積んでおこうと思います。

Webview × Expo (React Native) のハイブリッドアプリ

困ったこと

Web 版で実装した AikiNote の各種機能や画面を React Native でゼロから書き直すのは、どうしてもやりたくありませんでした。一方で、完全な WebView ラッパーにしてしまうと、Apple App Store の Review Guideline 4.2(Minimum Functionality)に引っかかる可能性が高いです。ガイドラインには Your app should include features, content, and UI that elevate it beyond a repackaged website.(アプリは、単に再パッケージ化された Web サイトを超える機能・コンテンツ・UI を持つべき)と書かれており、「却下する」と直接明示されているわけではないものの、Web をラップしただけのアプリが Guideline 4.2 で Reject される事例は開発者フォーラムでも頻繁に共有されています。

採用したアプローチ

Web 版を WebView の中に置いたまま、外側から徐々にネイティブ化していくことにしました。

  • Phase 1: 完全な WebView ラッパー + ディープリンク
  • Phase 2: ネイティブヘッダー・タブバーを導入。CSS インジェクションで Web 側のヘッダーを潰す
  • Phase 3: OAuth・プッシュ通知・IAP(アプリ内課金)をネイティブ実装

肝になるのが Phase 2〜3 の WebView ↔ Native ブリッジです。やっていることは、書き出してみるとそれほど複雑ではありませんでした。

まず、WebView 起動時に「自分はネイティブから呼ばれているよ」という目印を Web 側へ渡します。

// components/webview/aikinote-webview.tsx (抜粋)
const injectedJavaScriptBeforeContentLoaded = `
  window.__AIKINOTE_NATIVE_APP__ = true;
  true; // WebView は最後の式を返す必要がある
`;

次に、Next.js のクライアントサイド遷移をフックして、URL に応じた CSS を注入します。

// components/webview/aikinote-webview.tsx (抜粋)
const injectedJavaScript = `
  (function () {
    const updateStyle = () => {
      const path = location.pathname;
      const css = path.startsWith("/social")
        ? "header.app-header { display: none !important; }"
        : "header.app-header { display: none !important; } nav.tabbar { display: none !important; }";
      let tag = document.getElementById("__aikinote_native_style__");
      if (!tag) {
        tag = document.createElement("style");
        tag.id = "__aikinote_native_style__";
        document.head.appendChild(tag);
      }
      tag.textContent = css;
    };
    const _push = history.pushState;
    history.pushState = function () { _push.apply(this, arguments); updateStyle(); };
    const _replace = history.replaceState;
    history.replaceState = function () { _replace.apply(this, arguments); updateStyle(); };
    window.addEventListener("popstate", updateStyle);
    updateStyle();
  })();
  true;
`;

最後に、postMessage の型を type の文字列で振り分ける形に書き直しました。

// components/webview/messages.ts (抜粋)
export type WebToNativeMessage =
  | { type: "SEARCH_HISTORY_UPDATED"; payload: string[] }
  | { type: "USER_INFO"; payload: { id: string; locale: string } }
  | { type: "UNREAD_NOTIFICATION_COUNT"; payload: number }
  | { type: "TUTORIAL_STATE"; payload: "completed" | "skipped" }
  | { type: "INITIATE_IAP"; payload: { sku: string } }
  | { type: "SHOW_CUSTOMER_CENTER" }
  | { type: "GET_SUBSCRIPTION_STATUS" }
  | { type: "START_NATIVE_OAUTH"; payload: { provider: "google" | "apple" } };

学び

「Web アプリか、ネイティブアプリか」の二択ではなく、自身の技術スキルや Web 版 AikiNote を活かすために Webview と Expo のハイブリッドでつくるという選択肢は正解だったと思います。
Apple App Store Review Guideline 4.2 への対応としては、現時点でネイティブヘッダー、タブバー、プッシュ通知、IAP(RevenueCat 経由)、ネイティブ OAuth(Apple / Google)を実装し、「単なる WebView ラッパー」ではなくネイティブ機能を持つアプリとして審査に出せる状態となっています。今後テスターからのフィードバックを見ながら、優先度の高い画面から段階的にネイティブ実装へ置き換えていく予定です。
これからリリースに向けて最終調整し、近日中に App Store / Google Play Store でインストールできる状態までもっていく予定です。

おわりに

今回は、AikiNote の設計〜実装フェーズで実際にぶつかった課題とそこから学んだことを 3 つのトピックに分けて書き残してみました。

個人開発か会社の業務で携わる大規模開発かどうかにかかわらず、AIに教えてもらったりなにか作ってもらったりした時ではなく、実際に課題にぶつかったりして解決策を考えたり意思決定をしたりした時にエンジニアとしての大きな成長機会を得られると改めて実感しました。そして、AI に伴走してもらえて手を動かさなくても動くコードが生成される時代になったとはいえ、最後にその設計・実装コードの品質や機能に責任をもつのはやはり人間であり、意図しない変更やバグが混入しないようにちゃんとレビューしていく必要があると再確認する良い機会となりました。

最後までお読みいただき、ありがとうございました!!
よかったら AikiNote を触ってみてください🙏🏻

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?