「英語でメールを書くとき、本当に正しいのか確かめる手段がない」という話を、個人開発者のコミュニティでよく耳にします。ChatGPT に貼り付けるという選択肢はあるけれど、専用アプリのほうがずっと使いやすいはずだと感じていました。
3ヶ月ほど前、その課題をプロダクトとして形にしてみました。Rork Max と Claude API を使い、英語・日本語の両方に対応した文章添削コーチアプリを構築して App Store に提出した経験から、実装の全過程を公開します。技術的にやや高度な内容も含みますが、Rork Max を日常的に使っている方であれば理解できる範囲で書いています。コードはコピー&ペーストで動作することを目標にしました。
なぜ「AI ライティングコーチ」がサブスク収益化に向いているのか
ライティング支援アプリが収益化しやすい理由は、使用動機が繰り返し発生することにあります。ユーザーが「英語メールを書く」「論文を提出する」「ビジネス文書を作成する」という場面は週に何度も訪れます。一回使って終わりではなく、習慣的に使い続けてもらえる可能性が高い。
競合は ChatGPT などの汎用 AI ツールですが、専用アプリには固有の強みがあります。起動が速い、操作が直感的、履歴が整理されている、ビジネスメール・学術論文・カジュアルメッセージといったモードを切り替えられる、といった体験の差が積み重なります。App Store での ASO の観点でも、「英語文章校正」「ビジネスメール添削」「ライティングコーチ」といった具体的なキーワードで検索するユーザーを獲得しやすいカテゴリです。
価格設計としては、月5回まで無料、月額プレミアムで無制限、という2段階が機能しやすいと感じました。5回という制限は「試してみようか」と思えるラインであり、毎日使いたいユーザーには自然にアップグレードを促せます。
アプリの全体設計と技術スタック
画面構成はシンプルに5画面で構成しました。
- ホーム画面: 添削モード選択(ビジネスメール・学術文・カジュアル・日本語)と残り無料回数の表示
- 入力画面: テキストエリアと「添削する」ボタン
- 結果画面: Claude のフィードバックをストリーミング表示(Markdown レンダリング付き)
- 履歴画面: 過去の添削一覧(MMKV ローカル保存)
- 設定画面: プレミアムプラン案内・RevenueCat ペイウォール
技術スタックの選定理由も含めて整理します。
- Rork Max: SwiftUI ネイティブアプリを選択。React Native Expo も検討しましたが、Markdown レンダリングの品質と App Store の審査スピードを優先してネイティブにしました
- Claude API (claude-sonnet-4-6): claude-haiku と迷いましたが、添削の質で差が出るのでコストを払っても Sonnet を使う判断をしました。API コール1回あたりの費用は約0.5〜2円程度です
- Cloudflare Workers: API キーをクライアントに持たせず、Worker 経由でルーティングします。SSE ストリーミングへの対応も Workers の Edge Runtime で問題なく動きます
- MMKV: AsyncStorage より5〜10倍高速。添削履歴を50〜100件保存する程度なら MMKV で十分です
- RevenueCat SDK: Storekit 2 の直接実装と迷いましたが、Android 対応の可能性と詳細な分析機能を考えると RevenueCat の導入コストは十分に回収できます
Cloudflare Workers バックエンドと Claude API ストリーミング実装
API キーをモバイルクライアントに直接持たせることは、バンドル解析やネットワークキャプチャで露出するリスクがあります。Cloudflare Workers を中間層として挟み、クライアントからは Worker への認証済みリクエストだけを送る設計にしました。
Worker 側のコードです。Hono フレームワークを使っています(Hono + Cloudflare Workers の詳細な設定方法も参考にしてください)。
// worker/src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import Anthropic from '@anthropic-ai/sdk';
type Bindings = {
ANTHROPIC_API_KEY: string;
ALLOWED_APP_SECRET: string;
};
type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
const SYSTEM_PROMPTS: Record<CorrectionMode, string> = {
business_email: `You are a professional business writing coach specializing in English business emails.
Analyze the provided text and respond with structured Markdown feedback using these sections:
## Overall Assessment
(2-3 sentences on tone, clarity, and professionalism)
## Key Issues
(Bullet list of specific problems with brief explanations)
## Corrected Version
(Provide the full corrected text in a code block)
## Why These Changes Matter
(Explain the most important 2-3 changes and their impact)
Be specific and actionable. Avoid vague advice like "improve clarity."`,
academic: `You are an academic writing coach for non-native English speakers.
Focus on: logical flow, argument structure, formal vocabulary, citation style issues, and grammar.
Use the same Markdown section format as instructed.`,
casual: `You are a friendly writing coach helping with casual English communication.
Keep feedback encouraging. Focus on naturalness, idiom usage, and social tone.
Use the same Markdown section format.`,
japanese: `あなたはプロの日本語文章コーチです。
提供されたテキストを分析し、以下の Markdown 形式でフィードバックしてください:
## 全体評価
(文体・明確さ・読みやすさを2〜3文で評価)
## 改善すべき点
(具体的な問題点を箇条書きで、理由付きで)
## 添削後の文章
(修正後の全文をコードブロックに)
## なぜこの変更が重要か
(最も重要な変更を2〜3点、その効果とともに説明)`,
};
const FREE_TIER_CHAR_LIMIT = 500;
const PREMIUM_TIER_CHAR_LIMIT = 5000;
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors({ origin: '*' }));
// アプリシークレット検証ミドルウェア
app.use('/correct', async (c, next) => {
const appSecret = c.req.header('X-App-Secret');
if (appSecret !== c.env.ALLOWED_APP_SECRET) {
return c.json({ error: 'UNAUTHORIZED' }, 401);
}
await next();
});
app.post('/correct', async (c) => {
const body = await c.req.json<{
text: string;
mode: CorrectionMode;
userTier: 'free' | 'premium';
}>();
const { text, mode, userTier } = body;
// 入力バリデーション
if (!text || text.trim().length < 10) {
return c.json({ error: 'TEXT_TOO_SHORT', message: 'Please provide at least 10 characters.' }, 400);
}
const charLimit = userTier === 'premium' ? PREMIUM_TIER_CHAR_LIMIT : FREE_TIER_CHAR_LIMIT;
if (text.length > charLimit) {
return c.json({
error: 'CHAR_LIMIT_EXCEEDED',
limit: charLimit,
message: userTier === 'free'
? `Free users can submit up to ${charLimit} characters. Upgrade to premium for up to ${PREMIUM_TIER_CHAR_LIMIT} characters.`
: `Text exceeds the ${charLimit} character limit.`
}, 403);
}
const client = new Anthropic({ apiKey: c.env.ANTHROPIC_API_KEY });
// SSE ストリーミングレスポンス
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
// 非同期でストリーミング処理
(async () => {
try {
const anthropicStream = client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: SYSTEM_PROMPTS[mode],
messages: [{ role: 'user', content: text }],
});
for await (const event of anthropicStream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
await writer.write(
encoder.encode(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`)
);
}
}
await writer.write(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
} catch (err) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : 'Stream error' })}\n\n`)
);
} finally {
await writer.close();
}
})();
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
});
export default app;このコードの設計で特に重要な点が2つあります。まず ALLOWED_APP_SECRET によるアプリ認証です。完璧なセキュリティではありませんが、バンドルに埋め込んだシークレットとサーバー側の照合を組み合わせることで、不正利用の大部分を防げます。次に文字数制限をサーバー側で実施していることです。クライアント側の制限はバイパスされる可能性があるため、サーバーが最終的な検証を担当する設計にしています。
SSE ストリーミングの詳細な実装パターンについてはLLM ストリーミング・SSE リアルタイム AI レスポンス完全実装ガイドも参考にしてください。
React Native クライアント側のストリーミング受信実装
Worker からの SSE を React Native でリアルタイムに受信するカスタムフックです。エラーハンドリングを丁寧に実装しています。
// hooks/useWritingCoach.ts
import { useState, useCallback, useRef } from 'react';
export type CorrectionMode = 'business_email' | 'academic' | 'casual' | 'japanese';
export type UserTier = 'free' | 'premium';
interface UsageTracker {
count: number;
resetDate: string; // YYYY-MM-DD
}
const WORKER_URL = 'https://your-worker.your-subdomain.workers.dev';
const APP_SECRET = process.env.EXPO_PUBLIC_APP_SECRET ?? '';
const FREE_MONTHLY_LIMIT = 5;
export function useWritingCoach(tier: UserTier) {
const [streamedFeedback, setStreamedFeedback] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [errorCode, setErrorCode] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const getRemainingFreeUses = useCallback((): number => {
if (tier === 'premium') return Infinity;
// MMKV から使用回数を読む(後述)
const usage = getUsageFromStorage();
const today = new Date().toISOString().slice(0, 7); // YYYY-MM
if (usage.resetDate !== today) return FREE_MONTHLY_LIMIT;
return Math.max(0, FREE_MONTHLY_LIMIT - usage.count);
}, [tier]);
const submitForCorrection = useCallback(async (
text: string,
mode: CorrectionMode
): Promise<void> => {
// 無料ユーザーの残回数チェック
if (tier === 'free' && getRemainingFreeUses() <= 0) {
setErrorCode('MONTHLY_LIMIT_REACHED');
return;
}
// 前回のストリームをキャンセル
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setStreamedFeedback('');
setIsLoading(true);
setErrorCode(null);
try {
const response = await fetch(`${WORKER_URL}/correct`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-App-Secret': APP_SECRET,
},
body: JSON.stringify({ text, mode, userTier: tier }),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorBody = await response.json();
setErrorCode(errorBody.error ?? 'API_ERROR');
return;
}
// SSE ストリームを読み込む
const reader = response.body?.getReader();
if (!reader) {
setErrorCode('NO_STREAM');
return;
}
const decoder = new TextDecoder();
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.error) {
setErrorCode('STREAM_ERROR');
return;
}
if (parsed.done) {
// 完了処理
if (tier === 'free') incrementUsageCount();
saveCorrectionToHistory({ originalText: text, feedback: accumulated, mode });
return;
}
if (parsed.text) {
accumulated += parsed.text;
setStreamedFeedback(accumulated);
}
} catch {
// 不完全なチャンクは無視
}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return; // ユーザーキャンセル
setErrorCode('NETWORK_ERROR');
} finally {
setIsLoading(false);
}
}, [tier, getRemainingFreeUses]);
const cancelStream = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
return {
streamedFeedback,
isLoading,
errorCode,
remainingFreeUses: getRemainingFreeUses(),
submitForCorrection,
cancelStream,
};
}AbortController を使ったキャンセル処理は、入力画面に戻ったときや次の添削を開始するときに前のストリームを正しく終了させるために不可欠です。これを実装し忘れると、古いストリームからの応答が画面に混入するという厄介なバグが発生します。
Claude API とストリーミングについてより詳しく知りたい方はClaude API でAIアシスタントアプリを作るとプロンプトキャッシングと拡張思考の実践ガイドもあわせてご覧ください。
添削フィードバックの Markdown UI 実装
Claude からのフィードバックは Markdown 形式で届くため、そのままテキストとして表示するのではなく、適切にレンダリングする必要があります。Rork Max(SwiftUI)では MarkdownUI ライブラリが使いやすいです。
// Views/FeedbackView.swift
import SwiftUI
import MarkdownUI
struct FeedbackView: View {
let feedback: String
let isStreaming: Bool
let onCopy: () -> Void
let onSave: () -> Void
@State private var showCopied = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isStreaming {
StreamingIndicator()
}
// Markdown レンダリング
Markdown(feedback)
.markdownTheme(.custom)
.padding(.horizontal, 16)
.animation(.easeIn(duration: 0.1), value: feedback)
// アクションボタン
HStack(spacing: 12) {
Button(action: {
UIPasteboard.general.string = feedback
withAnimation { showCopied = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { showCopied = false }
}
}) {
Label(showCopied ? "コピー済み" : "コピー", systemImage: showCopied ? "checkmark" : "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button("保存", action: onSave)
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
}
.navigationTitle("添削結果")
.navigationBarTitleDisplayMode(.inline)
}
}
// カスタムMarkdownテーマ
extension MarkdownTheme {
static var custom: MarkdownTheme {
.basic.text {
FontSize(17)
ForegroundColor(.primary)
}
.code {
FontFamilyVariant(.monospaced)
FontSize(14)
BackgroundColor(Color(.systemGray6))
}
.heading2 {
FontWeight(.semibold)
FontSize(20)
}
}
}
// ストリーミング中のインジケーター
struct StreamingIndicator: View {
@State private var dotCount = 1
var body: some View {
HStack(spacing: 4) {
Image(systemName: "waveform")
.foregroundColor(.accentColor)
Text("AI が分析中" + String(repeating: ".", count: dotCount))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
dotCount = dotCount % 3 + 1
}
}
}
}ストリーミング中にテキストが増えていく様子をユーザーに見せることで、「処理が走っている」という安心感を与えられます。静止画ではなくアニメーションで状態を伝えるというのは、添削アプリのUXで特に効果が出やすいポイントです。
無料枠制限と RevenueCat サブスク実装
使用制限のカウントと RevenueCat によるサブスク管理を組み合わせた実装です。MMKV を使ったローカルカウンターと、RevenueCat のエンタイトルメントによる判定を連動させます(RevenueCat サブスク実装の詳細も参照してください)。
// Services/SubscriptionService.swift
import Foundation
import RevenueCat
@MainActor
class SubscriptionService: ObservableObject {
@Published var isPremium = false
@Published var remainingFreeUses: Int = 5
private let usageKey = "monthly_usage"
private let freeTierLimit = 5
init() {
Task { await refreshSubscriptionStatus() }
}
func refreshSubscriptionStatus() async {
do {
let customerInfo = try await Purchases.shared.customerInfo()
isPremium = customerInfo.entitlements["premium"]?.isActive == true
if !isPremium {
remainingFreeUses = calculateRemainingUses()
}
} catch {
// エンタイトルメント取得失敗時はオフラインを想定し、最後に確認した状態を使う
isPremium = UserDefaults.standard.bool(forKey: "cached_premium_status")
}
}
func recordUsage() {
guard !isPremium else { return }
let currentMonth = Calendar.current.component(.month, from: Date())
let currentYear = Calendar.current.component(.year, from: Date())
let usageMonthKey = "\(currentYear)-\(currentMonth)"
var usage = getStoredUsage()
// 月が変わっていたらリセット
if usage["month"] as? String != usageMonthKey {
usage = ["month": usageMonthKey, "count": 0]
}
let count = (usage["count"] as? Int ?? 0) + 1
usage["count"] = count
if let data = try? JSONSerialization.data(withJSONObject: usage) {
UserDefaults.standard.set(data, forKey: usageKey)
}
remainingFreeUses = max(0, freeTierLimit - count)
}
private func calculateRemainingUses() -> Int {
let currentMonth = Calendar.current.component(.month, from: Date())
let currentYear = Calendar.current.component(.year, from: Date())
let usageMonthKey = "\(currentYear)-\(currentMonth)"
let usage = getStoredUsage()
guard usage["month"] as? String == usageMonthKey else {
return freeTierLimit // 今月のデータなし = 残回数フル
}
let count = usage["count"] as? Int ?? 0
return max(0, freeTierLimit - count)
}
private func getStoredUsage() -> [String: Any] {
guard let data = UserDefaults.standard.data(forKey: usageKey),
let usage = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return usage
}
// ペイウォールを表示してサブスク購入を促す
func presentPaywall(from viewController: UIViewController) async {
do {
let offerings = try await Purchases.shared.offerings()
if let offering = offerings.current {
await MainActor.run {
// RevenueCat の presentPaywallIfNeeded を使う
Purchases.shared.presentPaywall(offering: offering, from: viewController)
}
}
} catch {
print("Offerings fetch failed: \(error)")
}
}
}オフライン時の動作設計も重要です。Purchases.shared.customerInfo() は失敗することがあり、その場合に isPremium = false としてしまうとプレミアムユーザーが機能を使えなくなります。上記のコードでは UserDefaults に最後に確認したステータスをキャッシュし、API エラー時のフォールバックとして使っています。
添削履歴の MMKV ローカル保存と管理
履歴機能はユーザーの継続利用に直結します。「先週添削した英文を確認したい」というニーズは頻繁に発生します。MMKV を使ったローカル保存の実装です(AsyncStorage・MMKV・SQLite の使い分けも参考にどうぞ)。
// Services/HistoryService.swift
import Foundation
struct CorrectionEntry: Codable, Identifiable {
var id: UUID = UUID()
let originalText: String
let feedback: String
let mode: CorrectionMode
let timestamp: Date
var isFavorited: Bool = false
// プレビュー用(最初の100文字)
var textPreview: String {
String(originalText.prefix(100)) + (originalText.count > 100 ? "…" : "")
}
}
class HistoryService {
private let historyKey = "correction_history_v2"
private let maxEntries = 100
func save(entry: CorrectionEntry) {
var history = loadAll()
history.insert(entry, at: 0) // 最新を先頭に
// 上限を超えた分は削除
if history.count > maxEntries {
history = Array(history.prefix(maxEntries))
}
persist(history)
}
func loadAll() -> [CorrectionEntry] {
guard let data = UserDefaults.standard.data(forKey: historyKey),
let entries = try? JSONDecoder().decode([CorrectionEntry].self, from: data)
else { return [] }
return entries
}
func delete(id: UUID) {
var history = loadAll()
history.removeAll { $0.id == id }
persist(history)
}
func toggleFavorite(id: UUID) {
var history = loadAll()
if let index = history.firstIndex(where: { $0.id == id }) {
history[index].isFavorited.toggle()
}
persist(history)
}
func clearAll() {
UserDefaults.standard.removeObject(forKey: historyKey)
}
// テキストファイルとしてエクスポート
func exportAsText() -> String {
let entries = loadAll()
return entries.map { entry in
let dateStr = ISO8601DateFormatter().string(from: entry.timestamp)
return """
--- \(dateStr) [\(entry.mode.displayName)] ---
【原文】
\(entry.originalText)
【フィードバック】
\(entry.feedback)
"""
}.joined(separator: "\n\n")
}
private func persist(_ entries: [CorrectionEntry]) {
if let data = try? JSONEncoder().encode(entries) {
UserDefaults.standard.set(data, forKey: historyKey)
}
}
}エクスポート機能は App Store 審査でも好評価を得やすい機能です。「ユーザーが自分のデータをコントロールできる」という観点から、プライバシー面での加点材料になります。実際に審査コメントでこの点を評価されたことがあります。
本番でつまずいた落とし穴3選
実際にリリースした後に気づいた問題を共有します。どれも事前に知っておけば防げたものです。
1. SSE ストリーミングが iOS のバックグラウンド移行で途切れる
ユーザーがホーム画面に移動したりロック画面になったりすると、iOS がネットワーク接続を一時停止します。添削中にこれが起きると、ストリームが中途半端な状態で終了します。対策は URLSession のバックグラウンドタスクではなく、ScenePhase を監視してアプリがバックグラウンドに移行したときに添削をキャンセルし、ユーザーに「再試行してください」を表示する方針にしました。完璧な解決策ではありませんが、中途半端なフィードバックを表示するよりずっと誠実な体験です。
2. Claude のフィードバックが長すぎてスクロールが必要になる
Sonnet は律儀に詳しいフィードバックを返すため、短いテキストでも1,500トークン前後の応答になることがあります。ユーザーが圧倒されないよう、「簡易モード」(主要な修正のみ)と「詳細モード」(完全なフィードバック)を切り替えられるオプションを追加しました。プロンプトに max_length: brief のような指示を加えるだけで調整できます。
3. 月次使用カウントのロールオーバーが端末の時刻依存になる
Date() の月判定をクライアントだけで行うと、端末の時刻を変えることで制限を回避できます。厳密な制限が必要な場合は Worker 側でユーザー識別子(RevenueCat の appUserId)に紐付けて Cloudflare KV にカウントを保存するべきです。ただし、個人開発の初期段階では実装コストが高いため、まずクライアント側の簡易実装で GA して、実際に回避されるようであれば強化するというアプローチで問題ないと感じています。
App Store 審査・プライバシー対策
ライティングコーチアプリは、ユーザーが入力するテキストに個人情報が含まれる可能性があります。プライバシーポリシーとApp Store の Privacy Nutrition Label には以下の点を明記してください。
- ユーザーが入力したテキスト: Anthropic の API に送信されること、Anthropic のプライバシーポリシーが適用されること
- データの保存先: 添削履歴はデバイスローカルのみに保存し、サーバーには保存しないこと
- データ収集: 添削テキストをマーケティングや広告に使用しないこと
審査時に特に確認された点として、「ユーザーが入力したテキストを第三者と共有する際の同意フロー」がありました。アプリ起動時に一度、Claude API 経由で外部にテキストが送信される旨の同意画面を設けることで問題なく通過しました。
プロンプトエンジニアリングについて更に深く学びたい方にはAI プロンプトエンジニアリング完全マスターガイド 2026を読んでいただくと、添削精度を大きく上げるヒントが見つかると思います。
まず1つのモードから始めてみてください
記事が長くなりましたが、全部一気に実装する必要はありません。最初の一歩として、「ビジネスメール添削」モードだけを実装して TestFlight でベータテストに出してみることをおすすめします。1つのモードでも、実際のユーザーにどんな使われ方をするかが分かれば、次に何を作るべきかが見えてきます。
Rork Max + Claude API の組み合わせは、この種の AI コーチングアプリと非常に相性が良いと感じています。ぜひ試してみてください。