💡 Tips

Expo × OpenAI APIでAI機能を実装する実践チュートリアル【個人開発】

この記事でわかること

  • ExpoアプリからOpenAI APIを呼ぶ最小構成
  • APIキーをクライアントに置かないサーバーサイドProxy(Cloudflare Workers)の構成
  • TypeScriptで型安全にレスポンスを扱う実装例
  • ストリーミング応答をリアルタイム表示する方法

個人開発のモバイルアプリにチャットや要約・翻訳などのAI機能を追加したいとき、最初の壁は「APIキーをどこに置くか」です。本記事ではセキュリティと開発スピードを両立する構成を、動くコードつきで解説します。


全体アーキテクチャ

[Expo アプリ (React Native)]
        ↓ HTTPS
[Proxy サーバー (Cloudflare Workers)]
        ↓ HTTPS + Authorization
[OpenAI API]

クライアント(アプリ)はProxyにリクエストを送るだけ。OpenAIのAPIキーはサーバー側の環境変数にだけ置くため、アプリをリバースエンジニアリングされてもキーは漏れません。


Step 1 — Expoプロジェクトのセットアップ

npx create-expo-app ai-chat-app --template blank-typescript
cd ai-chat-app
npx expo install expo-constants

.env.local にProxyのURLだけを記載します(キーは絶対に書かない)。

EXPO_PUBLIC_PROXY_URL=https://your-proxy.your-account.workers.dev

EXPO_PUBLIC_ プレフィックスが付いた変数はExpo SDKが自動的にクライアントバンドルに埋め込みます。URLは公開されても問題ない情報なので問題ありません。


Step 2 — Cloudflare WorkersでAPIキーProxyを作る

Cloudflare Workers は無料枠(1日10万リクエスト)があり、個人開発に最適です。

npm create cloudflare@latest ai-proxy -- --template hello-world-typescript
cd ai-proxy

src/index.ts を以下のように実装します。

export interface Env {
  OPENAI_API_KEY: string;
  ALLOWED_ORIGIN: string; // 例: "exp://192.168.x.x" や "https://yourdomain.com"
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // CORS プリフライト対応
    if (request.method === "OPTIONS") {
      return corsResponse(new Response(null, { status: 204 }), env);
    }

    if (request.method !== "POST") {
      return corsResponse(new Response("Method Not Allowed", { status: 405 }), env);
    }

    const body = await request.json<{ messages: { role: string; content: string }[] }>();

    const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-4o-mini",
        messages: body.messages,
        stream: true, // ストリーミング有効
      }),
    });

    // ストリームをそのままクライアントへ転送
    return corsResponse(
      new Response(openaiRes.body, {
        status: openaiRes.status,
        headers: { "Content-Type": "text/event-stream" },
      }),
      env
    );
  },
};

function corsResponse(res: Response, env: Env): Response {
  const headers = new Headers(res.headers);
  headers.set("Access-Control-Allow-Origin", env.ALLOWED_ORIGIN);
  headers.set("Access-Control-Allow-Headers", "Content-Type");
  return new Response(res.body, { status: res.status, headers });
}

APIキーをWorkerのSecret変数に登録します。

wrangler secret put OPENAI_API_KEY
wrangler secret put ALLOWED_ORIGIN
wrangler deploy

注意: 本番運用では Authorization ヘッダーや独自トークンでProxy自体も保護することを推奨します。


Step 3 — Expoアプリ側の実装

型定義

// types/chat.ts
export type Role = "user" | "assistant" | "system";

export interface Message {
  id: string;
  role: Role;
  content: string;
}

ストリーミング対応のカスタムフック

// hooks/useChat.ts
import { useState, useCallback } from "react";
import { Message } from "../types/chat";

const PROXY_URL = process.env.EXPO_PUBLIC_PROXY_URL!;

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const sendMessage = useCallback(async (userText: string) => {
    const userMsg: Message = {
      id: Date.now().toString(),
      role: "user",
      content: userText,
    };
    const history = [...messages, userMsg];
    setMessages(history);
    setIsLoading(true);

    // アシスタントの返答プレースホルダ
    const assistantId = (Date.now() + 1).toString();
    setMessages([...history, { id: assistantId, role: "assistant", content: "" }]);

    try {
      const res = await fetch(PROXY_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          messages: history.map(({ role, content }) => ({ role, content })),
        }),
      });

      if (!res.ok) throw new Error(`Proxy error: ${res.status}`);
      if (!res.body) throw new Error("No response body");

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let accumulated = "";

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        // SSE の "data: {...}" 行をパース
        const lines = chunk.split("\n").filter((l) => l.startsWith("data: "));
        for (const line of lines) {
          const data = line.slice(6);
          if (data === "[DONE]") break;
          try {
            const json = JSON.parse(data);
            const delta: string = json.choices[0]?.delta?.content ?? "";
            accumulated += delta;
            // リアルタイムでUIを更新
            setMessages((prev) =>
              prev.map((m) =>
                m.id === assistantId ? { ...m, content: accumulated } : m
              )
            );
          } catch {
            // パースエラーは無視
          }
        }
      }
    } catch (e) {
      console.error(e);
    } finally {
      setIsLoading(false);
    }
  }, [messages]);

  return { messages, isLoading, sendMessage };
}

チャット画面コンポーネント

// app/index.tsx
import { useState } from "react";
import {
  FlatList, KeyboardAvoidingView, Platform,
  StyleSheet, Text, TextInput, TouchableOpacity, View,
} from "react-native";
import { useChat } from "../hooks/useChat";
import { Message } from "../types/chat";

export default function ChatScreen() {
  const { messages, isLoading, sendMessage } = useChat();
  const [input, setInput] = useState("");

  const handleSend = () => {
    if (!input.trim() || isLoading) return;
    sendMessage(input.trim());
    setInput("");
  };

  const renderItem = ({ item }: { item: Message }) => (
    <View style={[styles.bubble, item.role === "user" ? styles.user : styles.assistant]}>
      <Text style={styles.text}>{item.content}</Text>
    </View>
  );

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === "ios" ? "padding" : undefined}
    >
      <FlatList
        data={messages}
        keyExtractor={(m) => m.id}
        renderItem={renderItem}
        contentContainerStyle={{ padding: 12 }}
      />
      <View style={styles.inputRow}>
        <TextInput
          style={styles.input}
          value={input}
          onChangeText={setInput}
          placeholder="メッセージを入力..."
          editable={!isLoading}
        />
        <TouchableOpacity style={styles.sendBtn} onPress={handleSend} disabled={isLoading}>
          <Text style={{ color: "#fff" }}>{isLoading ? "…" : "送信"}</Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: "#f5f5f5" },
  bubble: { marginVertical: 4, padding: 10, borderRadius: 12, maxWidth: "80%" },
  user: { alignSelf: "flex-end", backgroundColor: "#0a7ea4" },
  assistant: { alignSelf: "flex-start", backgroundColor: "#fff" },
  text: { color: "#111" },
  inputRow: { flexDirection: "row", padding: 8, backgroundColor: "#fff" },
  input: { flex: 1, borderWidth: 1, borderColor: "#ccc", borderRadius: 8, paddingHorizontal: 10 },
  sendBtn: { marginLeft: 8, backgroundColor: "#0a7ea4", borderRadius: 8, paddingHorizontal: 16, justifyContent: "center" },
});

Step 4 — 動作確認

npx expo start

iOSシミュレータ・Androidエミュレータ・実機どれでもOKです。ProxyのURLが正しく設定されていれば、入力したメッセージに対してAIの返答がストリーミングでリアルタイム表示されます。


よくある落とし穴と対処法

問題原因対処
Network request failedCORSの設定ミスWorkerのALLOWED_ORIGINとExpoのオリジンを一致させる
APIキーが undefined.env.local の変数名ミスEXPO_PUBLIC_ プレフィックスを確認
ストリームが途中で止まるProxyのタイムアウトWorkers の fetch は最大30秒。長い応答は注意
Androidでストリーム未対応Hermes の ReadableStream 制限Expo SDK 50以降 + expo-fetch ポリフィルで対応可能

セキュリティをさらに強化するには

  1. Proxy独自の認証トークン: アプリ起動時にサーバーが発行したJWTをリクエストヘッダーに付ける
  2. レートリミット: Cloudflare Workers KVで1ユーザーあたりのリクエスト数を制限
  3. プロンプトインジェクション対策: システムプロンプトをサーバー側で固定し、ユーザー入力の役割をuserに限定

本格的なサービス展開を考えるなら、VPSやXserverなどでNode.js/Pythonのプロキシサーバーを立てるのも選択肢です。スケールしやすいインフラとして {{A8:xserverVps}} も検討してみてください。

📚 おすすめ書籍

実践TypeScript BFFとNext.jsで作るプロダクション開発

TypeScript×APIの設計力をさらに深めたい方に

Amazonで見る →

まとめ

ポイント内容
APIキーの扱いサーバーサイド(Cloudflare Workers)の環境変数のみに保存
クライアントへの露出ProxyのURLのみ(EXPO_PUBLIC_PROXY_URL
ストリーミングSSEをReact Nativeのfetch ReadableStreamで受け取りリアルタイム表示
型安全性Message型・Role型でAPIレスポンスを厳密に管理

Expo × OpenAI APIの組み合わせは、個人開発でAI機能を素早く試すのに非常に向いています。まずはCloudflare Workersの無料枠でProxyを立ち上げ、最小構成から動かしてみてください。動いたら認証やレートリミットを段階的に足していくのが現実的なアプローチです。