OpenAIExpoJan 28, 2026, 2:45 PM

The solo dev playbook: ship faster with Expo, EAS Build, and OTA Updates

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

openaienmodel: gpt-5-mini-2025-08-07

The solo dev playbook: ship faster with Expo, EAS Build, and OTA Updates

Key Points

  • Ship cross-platform app in one week
  • EAS removes build and credential friction
  • OTA updates enable rapid post-release fixes

Summary

A solo founder used Expo + EAS to build and ship Wellspoken (cross-platform iOS/Android) in one week without writing native code. Key Expo modules (expo-audio, expo-camera, expo-notifications) provided simple hooks-based APIs for recording, playback, video capture, and push notifications. EAS Build/Update/Submit removed build configuration and credential overhead, and OTA updates enabled fast post-release fixes. The app's backend uses AssemblyAI/OpenAI for speech analysis, LangChain for orchestration, and RevenueCat for subscriptions.

Key Points

  • Cross-platform velocity: Expo SDK + EAS enabled shipping both platforms without Xcode/Android Studio or native code.
  • Audio: use useAudioRecorder/useAudioRecorderState and RecordingPresets.HIGH_QUALITY; recorder.uri is the uploadable file path.
  • Playback: useAudioPlayer and useAudioPlayerStatus for simple UI and seek handling.
  • Video: expo-camera with useCameraPermissions/useMicrophonePermissions, recordAsync({ maxDuration: 60 }) for timed clips, toggle front/back by changing the facing prop.
  • Notifications: expo-notifications handles permission flow and device token management; on Android setNotificationChannelAsync and use getPermissionsAsync/requestPermissionsAsync.
  • Builds and credentials: EAS Build handles signing, provisioning, and keystores; EAS Submit uploads to stores; EAS Update provides OTA pushes to iterate post-release.
  • Practical component patterns: encapsulate recording/playback into reusable components (e.g., RecordingButton, AudioPlaybackBar) and centralize permission logic.
  • Backend integration: upload recorder/video URIs to a backend for transcription/analysis (AssemblyAI/OpenAI); keep media upload and processing asynchronous to avoid blocking UI.

Practical Tips for Engineers

  • Centralize permission requests and show a single flow to users to avoid fragmentation.
  • Use presets (RecordingPresets) and maxDuration to normalize behavior across platforms.
  • Push critical bug fixes via EAS Update to avoid app store review delays.
  • Rely on EAS for signing to avoid manual provisioning profile/keystore management as a solo dev.
  • Reuse hooks-based Expo APIs to keep platform parity and reduce native debugging time.

Tech Stack Snapshot

React Native + Expo SDK 54, TypeScript, EAS Build/Update/Submit, expo-audio, expo-camera, expo-notifications, AssemblyAI, OpenAI, LangChain, RevenueCat.

Full Translation

Translations

A translation section that keeps the flow of the original article.

openaijamodel: gpt-5-mini-2025-08-07

ソロ開発者のプレイブック:Expo、EAS Build、OTA Updatesでより速くリリースする

ソロ開発者のプレイブック:Expo、EAS Build、OTA Updatesでより速くリリースする

Users • React Native • Development • 2026-01-28 • 13分読み物

Liam Du(ゲスト著者)

クロスプラットフォームアプリを迅速にビルドして出荷する方法:音声、カメラ、通知、安全なストレージ、そしてEAS Build、Update、Submitを使ったワークフロー。この投稿はWellspokenの創業者であり、より良いコミュニケーションの支持者であるLiam Duによるゲスト投稿です。

序盤の背景とアイデア

会議で自分の製品アイデアをチームに説明しようとしていました。頭の中ではコンセプトは明確で、よく考えていて理にかなっていると分かっていましたが、話し始めると言葉がうまく出てきませんでした。言い直したり、また最初の説明に戻ったり、話の筋を失ったり。何度も同じ会議で起きました。試みるたびに動揺していき、表現がさらに悪くなり、さらに動揺するという悪循環。正直恥ずかしかったです。自分が言いたいことは分かっている。アイデアも明確。しかし、思考とそれを明確に表現する能力の間にギャップがあり、特にプレッシャーや緊張時に顕著でした。

しばらくは自分だけの問題だと思っていましたが、他の場所でも同じ現象を目にするようになりました。話している内容は明確に理解しているのに、会話の中でうまく言語化できない賢い人たち。そこで、これを助けるツールを探しました。スピーチコーチ(高額)、パブリックスピーキングのコース(問題が違う)、声の投影やステージプレゼンスに特化したアプリ(形式が違う)などを見つけましたが、日常会話でその場で思考を構造化する能力を高める、という実際に必要な点を満たすものはありませんでした。

そこで、自分でアプリを作ることにしました。発話力のためのジム。日々の短いエクササイズで、リアルタイムで思考を整理し明確に表現する認知スキルを鍛えるアプリ。これをWellspokenと名付けました。

なぜExpoを選んだのか

私はiOSとAndroidの両方でわずか1週間でリリースすることを目標にしました。デザインとエンジニアリングを一人でこなすソロ開発者として、インフラやプラットフォーム固有の問題に時間を費やす余裕はありませんでした。実際のプロダクト構築に集中する必要がありました。Expoはこれを可能にしました。

  • ネイティブ開発不要:Expo SDKはSwiftやKotlinを書かずにネイティブ機能へアクセスさせてくれました。オーディオ録音、ファイルシステムアクセス、通知などはクリーンなTypeScript APIで扱えます。
  • ビルド設定地獄からの解放:EAS Buildがプラットフォーム固有のコンパイルを処理してくれました。XcodeやAndroid Studioを一度も開く必要はありませんでした。
  • 証明書管理不要:プロビジョニングプロファイル、署名証明書、キーストア類はEASが自動で処理してくれました。コマンドラインのチュートリアルに従うだけで完了しました。
  • リリース後の高速イテレーション:OTAアップデートにより、App Storeの審査を待たずにバグ修正や改善を配布できます。更新をプッシュし、次回アプリ起動でユーザーに適用されます。ローンチ直後に何度もこれに助けられました。

ソロ開発でスケジュールが厳しい場合、ネイティブ開発へ切り替えたり、認証情報管理に時間を取られる必要がないことが、出荷できるかどうかの差を生みました。

Wellspokenの機能とアーキテクチャ

コア機能:

  • パーソナライズされた練習セッション(模擬面接、トピック説明、役割演技など)
  • AIによる発話解析(どこで集中を失ったか、苦労した箇所を特定)
  • 日次5分エクササイズ
  • 進捗トラッキングと連続実施(streaks)
  • フリートライアル付きのサブスクリプション課金

テックスタック:

  • React Native + Expo SDK 54
  • TypeScript
  • Langchain
  • OpenAI API(発話解析用)
  • Assembly AI(文字起こし)
  • RevenueCat(サブスクリプション管理)
  • EAS Build、Update、Submit

どのようにExpoを実装したか

以下は私が使った主要なExpo機能と、それがWellspokenの構築をいかに容易にしたかの説明です。

  1. expo-audio:録音体験の核

オーディオ録音はWellspokenの基盤です。すべての練習モードで高品質な音声キャプチャが必要で、iOSとAndroidで一貫して動作することが求められました。ネイティブコードを書かずにexpo-audioがこれを単純にしてくれました。hooksベースのクリーンなAPIを提供します。

Code Copy // components/RecordingButton.tsx
import { useAudioRecorder , useAudioRecorderState , AudioModule , RecordingPresets , setAudioModeAsync } from 'expo-audio' ;
import * as Haptics from 'expo-haptics' ;
export function RecordingButton ( ) {
const recorder = useAudioRecorder ( RecordingPresets . HIGH_QUALITY ) ;
const recorderState = useAudioRecorderState ( recorder ) ;
const [ hasPermission , setHasPermission ] = useState ( false ) ;
async function startRecording ( ) {
try {
// Request permissions
const { status } = await AudioModule . requestRecordingPermissionsAsync ( ) ;
if ( status !== 'granted' ) return ;
// Configure audio mode
await setAudioModeAsync ( { allowsRecording : true , playsInSilentMode : true , } ) ;
// Prepare and start recording
await recorder . prepareToRecordAsync ( ) ;
recorder . record ( ) ;
Haptics . impactAsync ( Haptics . ImpactFeedbackStyle . Medium ) ;
} catch ( error ) {
console . error ( 'Failed to start recording:' , error ) ;
}
}
async function stopRecording ( ) {
try {
await recorder . stop ( ) ;
Haptics . notificationAsync ( Haptics . NotificationFeedbackType . Success ) ;
// recorder.uri contains the file path
const audioUri = recorder . uri ;
// Upload to backend for analysis...
} catch ( error ) {
console . error ( 'Failed to stop recording:' , error ) ;
}
}
return ( < TouchableOpacity onPress = { recorderState . isRecording ? stopRecording : startRecording } > < View style = { recorderState . isRecording ? styles . recording : styles . idle } > { recorderState . isRecording ? < StopIcon / > : < MicrophoneIcon / > } < / View > < / TouchableOpacity > ) ;
}

なぜこれが素晴らしいのか:

  • useAudioRecorderフックがすべての状態を管理
  • RecordingPresets.HIGH_QUALITYがプラットフォーム固有のエンコーディング設定を処理
  • 同じコードがiOSとAndroidの両方で動作
  • ネイティブモジュールの設定不要

このRecordingButtonコンポーネントはQ&A、フレームワーク練習、スピード分解、自由形式などすべての練習モードで再利用しています。一度書けばどこでも使えます。

オーディオ再生も同様にシンプルです:

Code Copy // components/AudioPlaybackBar.tsx
import { useAudioPlayer , useAudioPlayerStatus } from 'expo-audio' ;
export function AudioPlaybackBar ( { audioUri } : { audioUri : string } ) {
const player = useAudioPlayer ( audioUri ) ;
const status = useAudioPlayerStatus ( player ) ;
function togglePlayback ( ) {
if ( status . playing ) {
player . pause ( ) ;
} else {
player . play ( ) ;
}
}
return ( < View > < TouchableOpacity onPress = { togglePlayback } > { status . playing ? < PauseIcon / > : < PlayIcon / > } < / TouchableOpacity > < Slider value = { status . currentTime } maximumValue = { status . duration } onSlidingComplete = { ( value ) => player . seekTo ( value ) } / > < Text > { formatTime ( status . currentTime ) } / { formatTime ( status . duration ) } < / Text > < / View > ) ;
}

useAudioPlayerフックが再生状態を自動管理し、useAudioPlayerStatusは再生進捗をリアルタイムで提供します。

  1. expo-camera:Daily 60ビデオ練習

Wellspokenの機能の1つに「Daily 60」があります。これはユーザーがトピックを説明する60秒のビデオ練習です。フロント/バックの切替、ビデオ録画、カメラとマイクの権限取得が必要でした。expo-cameraがこれらを処理しました。

Code Copy // screens/practice/Daily60PracticeScreen.tsx
import { CameraView , useCameraPermissions , useMicrophonePermissions } from 'expo-camera' ;
import * as Haptics from 'expo-haptics' ;
export function Daily60PracticeScreen ( ) {
const [ cameraPermission , requestCameraPermission ] = useCameraPermissions ( ) ;
const [ micPermission , requestMicPermission ] = useMicrophonePermissions ( ) ;
const [ facing , setFacing ] = useState < 'front' | 'back' > ( 'front' ) ;
const [ isRecording , setIsRecording ] = useState ( false ) ;
const cameraRef = useRef < CameraView > ( null ) ;
async function startRecording ( ) {
if ( ! cameraRef . current ) return ;
try {
setIsRecording ( true ) ;
Haptics . impactAsync ( Haptics . ImpactFeedbackStyle . Heavy ) ;
// Automatically stops at 60 seconds
const video = await cameraRef . current . recordAsync ( { maxDuration : 60 , } ) ;
// video.uri contains the recorded video
await handleVideoComplete ( video . uri ) ;
} catch ( error ) {
console . error ( 'Recording failed:' , error ) ;
}
}
async function stopRecording ( ) {
if ( ! cameraRef . current ) return ;
cameraRef . current . stopRecording ( ) ;
}
function toggleCameraFacing ( ) {
setFacing ( current => current === 'front' ? 'back' : 'front' ) ;
Haptics . selectionAsync ( ) ;
}
// Request permissions if needed
if ( ! cameraPermission ?. granted || ! micPermission ?. granted ) {
return ( < View > < Text > Camera and microphone access needed for video practice < / Text > < Button onPress = { ( ) => { requestCameraPermission ( ) ; requestMicPermission ( ) ; } } > Grant Permissions < / Button > < / View > ) ;
}
return ( < View style = { { flex : 1 } } > < CameraView ref = { cameraRef } style = { { flex : 1 } } facing = { facing } mode = "video" onCameraReady = { ( ) => console . log ( 'Camera ready' ) } > < View style = { styles . controls } > < TouchableOpacity onPress = { toggleCameraFacing } > < FlipCameraIcon / > < / TouchableOpacity > < TouchableOpacity onPress = { isRecording ? stopRecording : startRecording } > < View style = { isRecording ? styles . stopButton : styles . recordButton } / > < / TouchableOpacity > < / View > < / CameraView > { isRecording && ( < View style = { styles . timer } > < Text > { recordingDuration } s / 60s < / Text > < / View > ) } < / View > ) ;
}

何が簡単にしているのか:

  • useCameraPermissionsフックが権限状態の管理を担当
  • カメラの切り替えはfacingプロパティを変えるだけ
  • recordAsyncのmaxDurationで自動的に60秒で停止
  • 同じカメラUIコードがiOSとAndroidの両方で動作

代替案としては、SwiftとKotlinで別々にネイティブなカメラコードを書き、異なる権限システムに対応し、2つのコードベースを管理する必要がありました。expo-cameraのおかげで、本来1週間かかる仕事が1日に短縮されました。

  1. expo-notifications:日々の練習リマインダー

ユーザーを日々戻らせることはWellspokenにとって重要でした。プッシュ通知で練習リマインダーを出したかったのですが、APNsやFCMの設定、デバイストークンの手動管理は避けたかった。expo-notificationsがすべてを処理してくれました。

Code Copy // contexts/AuthContext.tsx
import * as Notifications from 'expo-notifications' ;
import { Platform } from 'react-native' ;
// Configure notification behavior
Notifications . setNotificationHandler ( { handleNotification : async ( ) => ( { shouldShowAlert : true , shouldPlaySound : true , shouldSetBadge : false , } ) , } ) ;
async function registerForPushNotifications ( ) {
// Android notification channel setup
if ( Platform . OS === 'android' ) {
await Notifications . setNotificationChannelAsync ( 'default' , { name : 'default' , importance : Notifications . AndroidImportance . MAX , vibrationPattern : [ 0 , 250 , 250 , 250 ] , } ) ;
}
// Request permissions
const { status : existingStatus } = await Notifications . getPermissionsAsync ( ) ;
let finalStatus = existingStatus ;
if ( existingStatus !== 'granted' ) {
const { status } = await Notifications . requestPermissionsAsync ( ) ;
finalStatus = status ;
}
if ( finalStatus !== 'granted' ) {
return null ;
}
// Get the Expo push token
const tokenData = await Notifications

ここまでで示したように、expo-notificationsは権限要求、プラットフォーム固有のチャンネル設定、通知ハンドリングの標準化を行ってくれます(上記コードは途中で切れていますが、続きはExpoのドキュメント通りにトークン取得やサーバーへの登録を行います)。


注:この記事は元記事の一部を翻訳したものです。コードの断片は元のまま保持してあります。実装の詳細や完全なサンプルは公式ドキュメント(Expo、EAS、各ライブラリのリファレンス)を参照してください。