sue@blog ~ /posts/web-native-api-strategy
$ cd ../
$ cat post.metadata

Web/Native API層共通化戦略

date: 2026-02-28 Architecture Mobile
既存のReact Router v7 WebアプリにReact Nativeを後追いで導入した実プロジェクトの経験から、API層をどこまで共通化すべきかを4段階のレベルで整理する。OpenAPI + Orvalによる薄い共通化の実践パターンも紹介。

前提: このプロジェクトの状況

$ git log --oneline --graph

ここで紹介する内容は 「Webが先にあって、Nativeを後から足した」 プロジェクトでの話です。

もともと React Router v7(旧 Remix)で動いている Web アプリがあり、そこに React Native(Expo)のモバイルアプリを PoC(Proof of Concept)として追加 しました。既存の Web 向け API はすでに本番稼働していて、モバイルは「まず動くものを作って検証する」フェーズです。

timeline
# プロジェクトの時系列
2023~  Web (React Router v7) 本番稼働中
2025~  Mobile (React Native/Expo) PoC 開始 <-- いまここ

この「後追い」という文脈を理解していないと、「なぜ最初から共通化しなかったのか」という話になります。答えはシンプルで、モバイルが存在しなかったからです。

なぜ WebView ではダメだったのか

モバイル対応を検討するとき、最初に「WebView でラップすればいいのでは?」という選択肢が必ず上がります。実際に検討しました。結論として、WebView は採用しませんでした。

WebView の限界

constraints
# WebView で困ること
- Push通知のハンドリングが困難
- カメラ・写真ライブラリへのアクセスが煩雑
- 生体認証(Face ID / Touch ID)が使えない
- オフライン時の挙動が不安定
- ネイティブのナビゲーション体験が出せない
- App Store / Google Play の審査で弾かれるリスク

特に今回のプロジェクトでは以下が決定打でした:

1. Push通知が必須 - サービスの性質上、ユーザーへのリアルタイム通知が重要な機能でした。WebView内からFCMやAPNsを扱うのは、ネイティブブリッジを自前で書くことになり、WebViewのメリット(Web資産の流用)が相殺されます。

2. 決済フロー - アプリ内課金(IAP)を将来的に組み込む可能性がありました。WebView経由の決済はAppleのガイドライン上グレーゾーンで、審査リスクが高い。

3. パフォーマンス - 既存WebはSSR前提の設計で、React Router v7のloader/actionパターンに強く依存しています。これをWebViewで動かすと、サーバーとの往復が増えてモバイル回線でのレイテンシが目立つ。

4. UX の期待値 - PoCとはいえ、社内ステークホルダーに見せるものである以上、「Webをそのまま表示している」と見抜かれるレベルの体験では評価されない。ネイティブのジェスチャー、トランジション、スクロール挙動はWebViewでは再現できません。

decision-tree
# 判断フロー
WebView で十分?
├── Push通知不要 & 課金なし & 情報閲覧のみ → WebView でOK
└── それ以外 → React Native を検討

結果として「WebのAPI資産は流用するが、UI層はNativeで書く」という方針に落ち着きました。そうなると、APIクライアントをどこまで共通化するかが次の問題になります。

共通化の 4 レベル

API層の共通化にはグラデーションがあります。「全部共通」「全部バラバラ」の二択ではなく、段階的に考えるのがポイントです。

Lv.1 - 型定義のみ共通

最小限。TypeScriptの型定義だけを共有し、実装は完全に独立。

Lv.3 - Fetcher + エラーハンドリング共通

共通のエラー型、リトライロジック、認証ヘッダ付与まで共有。DI パターンが必要。

Lv.4 - Hooks・キャッシュ戦略まで共通

同じ TanStack Query hooks を Web/Native で共有。React Router v7 の loader を諦める判断が必要。

プロジェクト特性から判断する

共通化しやすい 分離した方がいい 今回の判断
チーム Web/Native 同一チーム 別チーム・別会社 同一チーム
リリース 同時リリース 別サイクル Web本番 / Mobile PoC
認証 統一可能(token) 根本的に違う Cognito SigV4 vs ID Token
オフライン 不要 Native で必須 現時点では不要
SSR 不要 Web で必須 Web で必須
API差分 完全同一 別エンドポイント 同一バックエンド

左寄りなら Lv.3〜4、右寄りなら Lv.1〜2。今回は混在しているため Lv.2 が最適解 という判断に。

共通化できるもの / 分離すべきもの

分離を検討すべき

  • HTTP クライアント設定(認証ヘッダ)
  • 認証トークンの取得・保存
    (cookie vs SecureStore)
  • エラー時の UI 処理
    (Toast vs Alert)
  • キャッシュ戦略
    (loader vs TanStack Query)

React Router v7 と React Native、何が違う?

React Router v7 (Web) React Native (Mobile)
データ取得 loader / action TanStack Query
キャッシュ フレームワーク管理 自前で設計
認証 cookie / Cognito SigV4 SecureStore + ID Token
レンダリング SSR 前提 クライアント完結
web - React Router v7 loader
// loader でサーバーサイドデータ取得
export async function loader({ params }: LoaderFunctionArgs) {
  const user = await getUser(params.userId);
  return { user };
}

export default function UserPage() {
  const { user } = useLoaderData<typeof loader>();
  return <UserProfile user={user} />;
}
mobile - TanStack Query
// TanStack Query でクライアントサイドデータ取得
export default function UserScreen() {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
  });

  if (isLoading) return <LoadingSpinner />;
  return <UserProfile user={user} />;
}

この違いがあるから、hooks以上の共通化(Lv.3〜4)は慎重になるべき。

実践: OpenAPI + Orval による薄い共通化

なぜ OpenAPI + Orval なのか

手書きで API クライアントを共通化しようとすると、「共通 fetcher を誰がメンテするのか」問題が発生します。PoC フェーズではスピードが重要で、共通モジュールのメンテに時間を割きたくない。

OpenAPI スキーマ(Single Source of Truth)から各プラットフォーム向けのクライアントを自動生成する ことで、共通化の恩恵を得つつ、メンテコストを最小化できます。

architecture
# OpenAPI からの自動生成フロー

OpenAPI 定義(1つ)
    │
    ├── orval → Web 用(SWR hooks + customFetch)
    ├── orval → Mobile 用(fetch + customFetch)
    └── orval → Backend 用(S2S fetch

Orval は OpenAPI スキーマから TypeScript の API クライアントコードを自動生成するツールです。出力形式を swrfetchzod から選べるのが特徴で、同じスキーマから用途別のクライアントを生成 できます。

モノレポ構成

directory structure
taiyaki/
├── openapi/
│   └── openapi.yml                # BFF の API 定義
├── packages/
│   ├── frontend/                  # React Router v7 (Web)
│   │   ├── orval.config.ts
│   │   ├── app/custom-fetch.ts    # Cognito + AWS SigV4
│   │   └── app/api-client/       # ← Orval 生成: SWR hooks
│   ├── mobile/                    # React Native (Expo)
│   │   ├── orval.config.ts
│   │   ├── src/clients/.../custom-fetch.ts  # Token 直接送信
│   │   └── src/clients/.../generated/       # ← Orval 生成: fetch
│   └── backend/                   # Hono BFF
│       └── src/handlers/          # ← Orval 生成: Hono handlers

Web 用 Orval 設定

packages/frontend/orval.config.ts
export default defineConfig({
  appClient: {
    input: {
      target: "../../core-api/openapi/_generated.yaml",
    },
    output: {
      mode: "tags-split",
      target: "./app/api-client",
      client: "swr",            // ← SWR hooks を生成
      httpClient: "fetch",
      mock: { type: "msw", delay: 0 },  // ← MSW モックも自動生成
      override: {
        mutator: {
          path: "app/custom-fetch.ts",
          name: "customFetch",   // ← Cognito 署名付き fetch
        },
      },
    },
  },
  // Zod バリデーションも同時生成
  appClientZod: {
    output: { client: "zod", fileExtension: ".zod.ts" },
  },
});

Mobile 用 Orval 設定

packages/mobile/orval.config.ts
export default defineConfig({
  appClient: {
    input: {
      target: "../../core-api/openapi/_generated.yaml",
    },
    output: {
      mode: "tags-split",
      target: "./src/clients/api-client/generated",
      client: "fetch",           // ← 素の fetch 関数を生成(hooks なし)
      httpClient: "fetch",
      override: {
        mutator: {
          path: "src/clients/api-client/custom-fetch.ts",
          name: "customFetch",   // ← Token 直接送信の fetch
        },
      },
    },
  },
});
同じ OpenAPI スキーマ から、Web は client: "swr" で SWR hooks を、Mobile は client: "fetch" で素の fetch 関数を生成。型定義と fetcher 関数は同じスキーマから導出されるので、型の不整合が起きない

customFetch: プラットフォーム別の認証

Orval の mutator 機能で、生成されたコードが呼ぶ fetch 関数を差し替えます。

Web: Cognito + AWS SigV4 署名
// packages/frontend/app/custom-fetch.ts
export const customFetch = async <T>(
  url: string, options: CustomFetchOptions
): Promise<T> => {
  // AWS SigV4 で署名
  const signedHeaders = await getSignedHeaders(url, options.method, options.body);
  // Cognito JWT を追加
  const cognitoJwt = await getCognitoUserPoolJwtToken();
  if (cognitoJwt) signedHeaders["X-Cognito-Jwt"] = cognitoJwt;

  const response = await fetch(url, {
    ...options, headers: { ...options.headers, ...signedHeaders },
  });
  if (!response.ok) throw { message: response.statusText, status: response.status };
  return getBody<T>(response);
};
Mobile: ID Token + リトライ
// packages/mobile/src/clients/api-client/custom-fetch.ts
export const customFetch = async <T>(
  url: string, options: RequestInit = {}
): Promise<T> => {
  const headers = new Headers(options.headers);
  headers.set("Content-Type", "application/json");

  // 認証サービスから ID Token を取得
  const idToken = await authService.getCurrentToken();
  if (idToken) headers.set("X-Id-Token", idToken);

  let response = await fetch(url, { ...options, headers });

  // 401 → トークンリフレッシュ → リトライ(1回のみ)
  if (response.status === 401) {
    const refreshed = await authService.refreshToken();
    if (refreshed) {
      headers.set("X-Id-Token", await authService.getCurrentToken());
      response = await fetch(url, { ...options, headers });
    }
  }
  if (!response.ok) throw new Error(await response.text());
  return await response.json();
};

生成されるコードの違い

同じ GET /users/{userId} エンドポイントから:

Web: SWR hooks (自動生成)
// app/api-client/user/user.ts
export const useGetUser = (userId: string) => {
  return useSWR(
    getGetUserKey(userId),
    () => customFetch<GetUserResponse>(`/users/${userId}`, { method: 'GET' })
  );
};
Mobile: fetch 関数 (自動生成) + 自前 hooks
// src/clients/api-client/generated/user/user.ts
export const getUser = (userId: string) => {
  return customFetch<GetUserResponse>(`/users/${userId}`, { method: 'GET' });
};

// Mobile 側で自前の hooks を書く
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => getUser(userId),
});

Zod バリデーション・MSW モックの自動生成

Orval は Zod スキーマや Mock Service Worker のハンドラも自動生成できます。APIが未完成の段階でもフロントエンド開発を進められる。

auto-generated outputs
app/api-client/
├── user/
│   ├── user.ts         # SWR hooks(Web)/ fetch関数(Mobile)
│   ├── user.zod.ts     # Zod バリデーションスキーマ
│   └── user.msw.ts     # MSW モックハンドラ(Web dev用)

補足: SWR の辛さと TanStack Query という選択肢

今回の Web 側では Orval の出力に client: "swr" を使っていますが、正直なところ SWR は自前実装が結構増えて辛い です。同じ用途なら TanStack Query(旧 React Query)の方が楽な場面が多い。ここではその比較を正直に書きます。

SWR で自前実装が必要になる 7 つの機能

機能 SWR TanStack Query
Mutation useSWRMutation + 手動キャッシュ操作 useMutation で Optimistic Update + 自動ロールバック
キャッシュ無効化 文字列キーを手動列挙。リファクタで漏れる invalidateQueries で述語関数・部分一致
Optimistic Updates スナップショット管理とロールバックを手動実装 onMutate / onError / onSettled のライフサイクルフック
Infinite Query useSWRInfinite で終了判定・ページ結合を手動 useInfiniteQuery で hasNextPage 自動管理
Devtools 公式なし。キャッシュ状態のデバッグが辛い 公式 Devtools でキャッシュ可視化・手動操作
リトライ制御 onErrorRetry に全ロジック手書き ステータスコード別リトライ、指数バックオフ標準
Dependent Queries 条件付きキー(三項演算子)で暗黙的 enabled オプションで宣言的

特に辛い: Optimistic Update

SWR: 手動ロールバック実装
// SWR: Optimistic Update を自前で書く
const { trigger } = useSWRMutation('/api/todos', updateTodo, {
  optimisticData(current) {
    // スナップショット保存 + 楽観更新を手動で
    return current.map(t => t.id === id ? { ...t, done: true } : t);
  },
  rollbackOnError: true,
  // revalidate でサーバーと同期
  revalidate: true,
  // ← 関連する他のキャッシュは? 手動で mutate() を呼ぶ必要あり
});
TanStack Query: 宣言的に書ける
// TanStack Query: ライフサイクルフックで宣言的
const mutation = useMutation({
  mutationFn: updateTodo,
  async onMutate(newTodo) {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], old =>
      old.map(t => t.id === newTodo.id ? { ...t, ...newTodo } : t)
    );
    return { previous }; // ← ロールバック用
  },
  onError(err, vars, context) {
    queryClient.setQueryData(['todos'], context.previous); // 自動ロールバック
  },
  onSettled() {
    queryClient.invalidateQueries({ queryKey: ['todos'] }); // 関連キャッシュも一括
  },
});

特に辛い: キャッシュ無効化

SWR: キー列挙が漏れる
// SWR: 関連キャッシュを手動で列挙
await mutate('/api/users/123');
await mutate('/api/users');           // ← 一覧も忘れずに
await mutate('/api/users/123/posts'); // ← これ忘れがち
await mutate('/api/teams/456');       // ← チーム情報にユーザー名が...
// → エンドポイントが増えるたびに追加漏れのリスク
TanStack Query: 述語関数で一括
// TanStack Query: パターンマッチで一括無効化
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'users' ||
    (query.queryKey[0] === 'teams' && query.state.data?.members?.includes(userId))
});

Orval との相性

Orval は TanStack Query の mutation フックも自動生成できる が、SWR 向けには query 用フックしか生成されない。mutation は手動で書く必要がある。

つまり、Orval + SWR の組み合わせでは、読み取り系は自動生成できるが、書き込み系は自前実装 になる。Orval + TanStack Query なら両方とも自動生成。

React Router v7 loader との統合

React Router v7 の loader でデータを取得し、コンポーネントで SWR を使う場合、キャッシュが二重管理 になる問題があります。

SWR: loader との統合が難しい
// loader でデータを取得
export async function loader() {
  const user = await getUser(userId); // サーバーサイドで取得
  return { user };
}

// コンポーネントで SWR を使うと...
function UserPage() {
  const { user: initialUser } = useLoaderData();
  const { data: user } = useSWR('/api/users/123', fetcher, {
    fallbackData: initialUser, // ← loader のデータを初期値に
  });
  // → loader のキャッシュと SWR のキャッシュが別物。同期が面倒。
}
TanStack Query: loader と同じキャッシュを共有
// loader で prefetch → コンポーネントと同じキャッシュ
export async function loader() {
  await queryClient.ensureQueryData({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
  });
  return null; // データはキャッシュ経由で渡る
}

function UserPage() {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
  });
  // → loader と同じキャッシュ。初回はサーバー取得、以降はクライアントキャッシュ。
}

なぜ今回 SWR を使っているのか

ここまで書くと「じゃあ TanStack Query にすればいいのでは」と思われるかもしれません。実際その通りなのですが、今回は 既存の Web アプリが SWR で実装済み だったため、PoC フェーズで移行コストをかける判断をしていません。

Mobile 側は新規なので TanStack Query を採用しています。Web 側は SWR のまま運用を継続する方針です。

現実的な判断: 既存 Web は SWR のまま、新規 Mobile は TanStack Query。Orval で生成するクライアントの形式が違うだけで、OpenAPI スキーマという共通基盤は変わらない。それぞれのプラットフォームに最適なライブラリを使い分けるのが、このアーキテクチャの強み。

実プロジェクトで見る: SWR と TanStack Query の実装差

「ライブラリの比較表」は世の中にたくさんあります。ここでは 同じプロジェクト内で両方を使っている実装 から、具体的にどう変わるのかを見ていきます。

1. 認証付きデータ取得(Read)

ユーザー情報を取得する GET /me エンドポイント。AWS SigV4 署名 + ID Token が必要なケースです。

SWR: useGetMe.ts(実装中)
export const useGetMe = () => {
  const { isAuthenticated } = useAuthContext();
  const url = isAuthenticated
    ? `${resourceApiBaseUrl}/me`
    : null;  // ← null キーで fetch を抑制

  return useSWR<GetMeResponse>(
    url,
    async (fetchUrl: string) => {
      const idToken = await getOrRefreshIdToken();
      const signedHeaders = await getSignedHeaders({
        url: fetchUrl, method: "GET",
        requestBody: "", idToken,
      });

      const response = await fetch(fetchUrl, {
        method: "GET", headers: signedHeaders,
      });
      if (!response.ok) throw new Error(`${response.status}`);
      return await response.json();
    },
  );
};
TanStack Query: 同じ処理を書くとこうなる
export const useGetMe = () => {
  const { isAuthenticated } = useAuthContext();

  return useQuery({
    queryKey: ['me'],
    queryFn: async () => {
      const idToken = await getOrRefreshIdToken();
      const signedHeaders = await getSignedHeaders({
        url: `${resourceApiBaseUrl}/me`, method: "GET",
        requestBody: "", idToken,
      });

      const response = await fetch(`${resourceApiBaseUrl}/me`, {
        method: "GET", headers: signedHeaders,
      });
      if (!response.ok) throw new Error(`${response.status}`);
      return await response.json();
    },
    enabled: isAuthenticated,  // ← 宣言的に fetch を制御
  });
};

Read だけなら大差はない。 SWR は null キー、TanStack Query は enabled オプションで条件付きフェッチを制御します。fetcher 関数の中身は同じで、差は API surface だけ。

2. データ更新(Mutation)— ここで差が開く

会員コンテンツの「視聴済み」を記録するケース。SWR ではフック外に関数を書くしかありませんが、TanStack Query は useMutation でキャッシュ更新まで宣言的に扱えます。

SWR: mutation は hooks の外に書く
// hooks とは別に、素の async 関数として定義
export const recordViewActivity = async (
  serviceId: string, contentId: string
): Promise<boolean> => {
  const idToken = await getOrRefreshIdToken();
  if (!idToken) return false;

  const url = `${baseUrl}/services/${serviceId}/membership-contents/${contentId}/activities`;
  const body = JSON.stringify({ type: "view" });
  const signedHeaders = await getSignedHeaders({ url, method: "POST", requestBody: body, idToken });

  const response = await fetch(url, {
    method: "POST",
    headers: { ...signedHeaders, "Content-Type": "application/json" },
    body,
  });
  return response.ok;
};

// コンポーネント側: SWR のキャッシュ更新は手動
const { mutate } = useMembershipActivities(serviceId);

const handleView = async () => {
  await recordViewActivity(serviceId, contentId);
  mutate();  // ← 手動で再検証。関連キャッシュは?
};
TanStack Query: useMutation でまとまる
export const useRecordViewActivity = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ serviceId, contentId }: Params) => {
      const idToken = await getOrRefreshIdToken();
      const url = `${baseUrl}/services/${serviceId}/membership-contents/${contentId}/activities`;
      const body = JSON.stringify({ type: "view" });
      const signedHeaders = await getSignedHeaders({ url, method: "POST", requestBody: body, idToken });

      const response = await fetch(url, {
        method: "POST",
        headers: { ...signedHeaders, "Content-Type": "application/json" },
        body,
      });
      if (!response.ok) throw new Error(`${response.status}`);
    },
    onSuccess(_, { serviceId }) {
      // 関連キャッシュを一括無効化
      queryClient.invalidateQueries({
        queryKey: ['membership-activities', serviceId],
      });
      queryClient.invalidateQueries({
        queryKey: ['membership-contents', serviceId],
      });
    },
  });
};

// コンポーネント側: mutation の状態もフックで管理
const { mutate, isPending } = useRecordViewActivity();
const handleView = () => mutate({ serviceId, contentId });
SWR の mutation は「hooks の外の世界」になる。
fetcher 関数をフック外に定義し、呼び出し後に mutate() で手動キャッシュ更新。TanStack Query は useMutation で mutation もフック内に閉じ込められるため、ローディング状態(isPending)やエラー状態の管理が宣言的。関連キャッシュの無効化も onSuccess で一箇所にまとまる。

3. CRUD 操作 — TanStack Query の真価

レッスンの作成・更新・削除を含む CRUD 操作で、実装量の差が顕著になります。

TanStack Query: CRUD hooks
// 一覧取得
export const useLessons = () =>
  useQuery({ queryKey: ['lessons'], queryFn: () => getLessons() });

// 作成
export const useCreateLesson = () => {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: createLesson,
    onSuccess: () => qc.invalidateQueries({ queryKey: ['lessons'] }),
  });
};

// 更新(詳細キャッシュも無効化)
export const useUpdateLesson = () => {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<Lesson> }) =>
      updateLesson(id, data),
    onSuccess: (_, { id }) => {
      qc.invalidateQueries({ queryKey: ['lessons'] });
      qc.invalidateQueries({ queryKey: ['lesson', id] });
    },
  });
};

// 削除
export const useDeleteLesson = () => {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: deleteLesson,
    onSuccess: () => qc.invalidateQueries({ queryKey: ['lessons'] }),
  });
};

SWR で同じ CRUD を書くと、create / update / delete はすべて素の async 関数 + 手動 mutate() になります。hooks としてまとめようとすると useSWRMutation を使うことになりますが、一覧キャッシュと詳細キャッシュの両方を無効化するようなケースでは、キー文字列を手動列挙する必要がある。

4. Provider 設定 — 共存は可能

実際のプロジェクトでは SWR と TanStack Query を 同時に使っています。Provider はネストするだけ。

AppProvider.tsx: 両方のキャッシュが共存
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: 1, refetchOnWindowFocus: false },
  },
});

export const AppProvider = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    <SWRConfig value={globalSWROptions}>
      <AuthProvider>
        {children}
      </AuthProvider>
    </SWRConfig>
  </QueryClientProvider>
);

2 つのキャッシュストアが独立して動く。これはデメリットにも見えますが、移行期においては「壊さずに段階移行できる」という大きなメリットがあります。

使い分けの判断基準

用途 SWR が向いている TanStack Query が向いている
Read のみ o 十分 o 十分
Mutation あり 手動キャッシュ操作 useMutation で宣言的
Optimistic Update スナップショット管理を自前 ライフサイクルフック標準
キャッシュ無効化 キー文字列を列挙 述語関数で一括
バンドルサイズ ~5KB gzipped ~16KB gzipped
既存 Orval 連携 query のみ自動生成 query + mutation 自動生成
Read が中心なら SWR、Mutation が増えたら TanStack Query。
今回のモバイルは PoC フェーズで Read 中心のため SWR で実装し、CRUD が必要な画面は TanStack Query で書いている。同じプロジェクト内で共存できるので、「全部書き換え」の判断を先延ばしにできる。

まとめ: 後追い Native で Lv.2 が最適解な理由

Lv.1 Lv.2 Lv.3 Lv.4
型の共有 o o o o
fetcher 共有 x o o o
エラーHD 共有 x x o o
hooks 共有 x x x o
導入コスト 低〜中
RR v7 loader 活用 o o o x
PoC スピード o o x
「既存 Web を壊さず、Mobile を速く立ち上げる」なら、Lv.2 + OpenAPI/Orval が現時点での最適解。

PoC で検証が進み、Mobile が本格運用フェーズに入ったら Lv.3 への段階的な移行を検討すればいい。最初から完璧な共通化を目指すより、今の状況に合ったレベルを選んで、必要に応じてレベルを上げていくのが現実的なアプローチ。

React Tokyo Fes 2026 ポスターセッション「React Router x React Native APIクライアント どこまで共通化する?」の補助資料。

$ _|