取り組みの背景:なぜモノレポが個人開発を変えるのか
複数の Rork アプリを運営していると、同じようなコードを何度も書いていることに気づきます。認証ロジック、APIクライアント、共通のUIコンポーネント、Stripeの決済フロー——これらを各アプリで個別に管理していると、バグ修正一つとっても全アプリへの反映が必要になり、気づかないうちに実装が微妙にずれていきます。
Turborepo を使ったモノレポ設計は、この問題を根本から解決します。複数のアプリとパッケージを 1 つのリポジトリで管理し、共通コードを一元化することで、変更一箇所で全アプリに即座に反映される環境を実現できます。
個人開発者にとってモノレポは「大企業の仕組み」というイメージがあるかもしれませんが、実際には小規模のマルチアプリ運営でこそ真価を発揮します。ここで扱うのはRork で生成した Expo アプリを Turborepo で管理する具体的な方法を、実装コードとともに詳しく解説します。
前提知識と準備
想定する読者と環境
- Rork / Rork Max で 2 つ以上のアプリを開発・運営している
- TypeScript の基礎知識がある
- npm workspace または yarn workspace に触れたことがある
- Node.js 20.x 以上、npm 10.x 以上がインストール済み
モノレポで解決できる課題
課題 1: コードの重複
認証フォーム、カスタムフック、APIクライアントなど、複数アプリで全く同じコードが存在する
課題 2: 型の不整合
アプリ A のユーザー型定義とアプリ B のユーザー型定義がいつの間にか乖離する
課題 3: 更新の漏れ
ライブラリのアップデートをアプリ A には適用したがアプリ B には忘れていた
課題 4: ビルドの非効率
変更のないパッケージも毎回フルビルドしてしまう
Turborepo はこれら全ての課題に対応できます。
Step 1: Turborepo モノレポの基本構造
ディレクトリ構成の設計
Rork アプリを Turborepo で管理する際の推奨ディレクトリ構成は以下の通りです。
my-apps/
├── apps/
│ ├── app-a/ ← Rorkで生成したExpoアプリA
│ │ ├── app/
│ │ ├── components/
│ │ ├── package.json
│ │ └── app.json
│ └── app-b/ ← Rorkで生成したExpoアプリB
│ ├── app/
│ ├── components/
│ ├── package.json
│ └── app.json
├── packages/
│ ├── ui/ ← 共有UIコンポーネントライブラリ
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config/ ← 共有設定(ESLint, TSConfig等)
│ │ ├── eslint/
│ │ ├── typescript/
│ │ └── package.json
│ ├── hooks/ ← 共有カスタムフック
│ │ ├── src/
│ │ └── package.json
│ └── api/ ← 共有APIクライアント
│ ├── src/
│ └── package.json
├── package.json ← ルートpackage.json(workspaces設定)
├── turbo.json ← Turborepoパイプライン設定
└── tsconfig.json ← ルートTSConfig
ルートの package.json を設定する
{
"name": "my-rork-apps",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0"
},
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules"
},
"packageManager": "npm@10.0.0"
}
turbo.json でパイプラインを定義する
Turborepo の核心は turbo.json のパイプライン設定です。タスクの依存関係とキャッシュ戦略をここで定義します。
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".expo/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"outputs": []
},
"type-check": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"cache": true
},
"clean": {
"cache": false
}
}
}
"dependsOn": ["^build"] の ^ プレフィックスは「依存パッケージの build タスクが完了してから実行する」という意味です。これにより、packages/ui のビルドが完了してから apps/app-a のビルドが始まるという正しい順序が自動的に制御されます。
Step 2: 共有 UI コンポーネントライブラリの構築
packages/ui の package.json
{
"name": "@my-apps/ui",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
},
"devDependencies": {
"@my-apps/config": "*",
"react": "^18.2.0",
"react-native": "^0.74.0"
}
}
重要: main に src/index.ts を直接指定することで、ビルドステップなしに型補完が効くようになります。個人開発では余計なビルドステップを省くことが開発速度の向上につながります。
共有ボタンコンポーネントの実装例
// packages/ui/src/components/Button.tsx
import React from 'react';
import {
TouchableOpacity,
Text,
ActivityIndicator,
StyleSheet,
ViewStyle,
TextStyle,
} from 'react-native';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
onPress: () => void;
title: string;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
}
// アプリ全体で統一されたデザインシステムを実現する共有ボタン
export const Button: React.FC<ButtonProps> = ({
onPress,
title,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
}) => {
const isDisabled = disabled || loading;
return (
<TouchableOpacity
onPress={onPress}
disabled={isDisabled}
style={[
styles.base,
styles[variant],
styles[`size_${size}`],
isDisabled && styles.disabled,
style,
]}
activeOpacity={0.8}
>
{loading ? (
<ActivityIndicator
color={variant === 'primary' ? '#ffffff' : '#6366f1'}
size="small"
/>
) : (
<Text style={[styles.text, styles[`text_${variant}`], textStyle]}>
{title}
</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
base: {
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
primary: {
backgroundColor: '#6366f1',
},
secondary: {
backgroundColor: '#f1f5f9',
borderWidth: 1,
borderColor: '#e2e8f0',
},
danger: {
backgroundColor: '#ef4444',
},
ghost: {
backgroundColor: 'transparent',
},
disabled: {
opacity: 0.5,
},
size_sm: { paddingHorizontal: 12, paddingVertical: 8, minHeight: 36 },
size_md: { paddingHorizontal: 16, paddingVertical: 12, minHeight: 44 },
size_lg: { paddingHorizontal: 24, paddingVertical: 16, minHeight: 52 },
text: { fontWeight: '600', fontSize: 15 },
text_primary: { color: '#ffffff' },
text_secondary: { color: '#374151' },
text_danger: { color: '#ffffff' },
text_ghost: { color: '#6366f1' },
});
packages/ui/src/index.ts でエクスポートを管理
// packages/ui/src/index.ts
// UIコンポーネントのバレルエクスポート
export { Button } from './components/Button';
export type { ButtonVariant, ButtonSize } from './components/Button';
export { Card } from './components/Card';
export { Input } from './components/Input';
export { Avatar } from './components/Avatar';
export { Badge } from './components/Badge';
export { Modal } from './components/Modal';
export { Toast, useToast } from './components/Toast';
export { LoadingSpinner } from './components/LoadingSpinner';
export { EmptyState } from './components/EmptyState';
// テーマトークン
export { colors } from './theme/colors';
export { spacing } from './theme/spacing';
export { typography } from './theme/typography';
Step 3: 共有型定義パッケージの設計
型の不整合は複数アプリ運営の大きな落とし穴です。@my-apps/types パッケージで共通の型定義を一元管理することで、アプリ間の型安全性を担保します。
パッケージ構成
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
displayName: string;
avatarUrl?: string;
createdAt: string;
subscription: SubscriptionTier;
}
export type SubscriptionTier = 'free' | 'pro' | 'premium';
// ユーザー作成時の入力型(id/createdAt は自動生成のため除外)
export type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// ユーザー更新時はすべてのフィールドをオプションに
export type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>;
// packages/types/src/api.ts
// APIレスポンスの統一型
export interface ApiResponse<T> {
data: T;
error: null;
status: 'success';
}
export interface ApiError {
data: null;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
status: 'error';
}
export type ApiResult<T> = ApiResponse<T> | ApiError;
// ページネーション付きリストレスポンス
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
// packages/types/src/index.ts
export * from './user';
export * from './api';
export * from './payment';
export * from './notification';
アプリ内での型の利用
// apps/app-a/app/(tabs)/profile.tsx
import { User, UpdateUserInput } from '@my-apps/types';
import { Button } from '@my-apps/ui';
// 型が完全に共有されているため、型エラーをビルド時に検出できる
const updateProfile = async (input: UpdateUserInput): Promise<User> => {
// ...
};
Step 4: 共有カスタムフックの実装
packages/hooks には、複数アプリで再利用できる React Native カスタムフックを配置します。
認証フックの共有化
// packages/hooks/src/useAuth.ts
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { User } from '@my-apps/types';
const AUTH_TOKEN_KEY = '@auth_token';
const AUTH_USER_KEY = '@auth_user';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
}
interface UseAuthReturn extends AuthState {
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
}
// すべてのアプリで共通の認証ロジック
// 各アプリは apiBaseUrl のみを差し替えるだけで使用可能
export const createUseAuth = (apiBaseUrl: string) => {
return (): UseAuthReturn => {
const [state, setState] = useState<AuthState>({
user: null,
token: null,
isLoading: true,
isAuthenticated: false,
});
// 初期化時にストレージから認証情報を復元
useEffect(() => {
const restoreSession = async () => {
try {
const [token, userJson] = await AsyncStorage.multiGet([
AUTH_TOKEN_KEY,
AUTH_USER_KEY,
]);
const storedToken = token[1];
const storedUser = userJson[1] ? JSON.parse(userJson[1]) : null;
if (storedToken && storedUser) {
setState({
user: storedUser,
token: storedToken,
isLoading: false,
isAuthenticated: true,
});
} else {
setState(prev => ({ ...prev, isLoading: false }));
}
} catch {
setState(prev => ({ ...prev, isLoading: false }));
}
};
restoreSession();
}, []);
const signIn = useCallback(async (email: string, password: string) => {
const response = await fetch(`${apiBaseUrl}/auth/sign-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'サインインに失敗しました');
}
const { user, token } = await response.json();
// ストレージに永続化
await AsyncStorage.multiSet([
[AUTH_TOKEN_KEY, token],
[AUTH_USER_KEY, JSON.stringify(user)],
]);
setState({ user, token, isLoading: false, isAuthenticated: true });
}, []);
const signOut = useCallback(async () => {
await AsyncStorage.multiRemove([AUTH_TOKEN_KEY, AUTH_USER_KEY]);
setState({ user: null, token: null, isLoading: false, isAuthenticated: false });
}, []);
const updateUser = useCallback((updates: Partial<User>) => {
setState(prev => ({
...prev,
user: prev.user ? { ...prev.user, ...updates } : null,
}));
}, []);
return { ...state, signIn, signOut, updateUser };
};
};
// apps/app-a/lib/auth.ts — アプリ固有のエンドポイントで初期化
import { createUseAuth } from '@my-apps/hooks';
export const useAuth = createUseAuth('https://api.app-a.com');
// apps/app-b/lib/auth.ts — 別のエンドポイントで同じロジックを再利用
import { createUseAuth } from '@my-apps/hooks';
export const useAuth = createUseAuth('https://api.app-b.com');
インターネット接続状態フック
// packages/hooks/src/useNetworkStatus.ts
import { useState, useEffect } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
interface NetworkStatus {
isConnected: boolean;
isInternetReachable: boolean | null;
connectionType: string | null;
}
export const useNetworkStatus = (): NetworkStatus => {
const [status, setStatus] = useState<NetworkStatus>({
isConnected: true,
isInternetReachable: null,
connectionType: null,
});
useEffect(() => {
// 初期状態を取得
NetInfo.fetch().then((state: NetInfoState) => {
setStatus({
isConnected: state.isConnected ?? true,
isInternetReachable: state.isInternetReachable,
connectionType: state.type,
});
});
// 接続状態の変化を監視
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
setStatus({
isConnected: state.isConnected ?? true,
isInternetReachable: state.isInternetReachable,
connectionType: state.type,
});
});
return unsubscribe;
}, []);
return status;
};
Step 5: Rork 生成アプリをモノレポに統合する
Rork で生成した既存アプリをモノレポに移行する手順を解説します。
既存アプリの package.json を更新
Rork で生成されたアプリの package.json に、共有パッケージへの依存を追加します。
{
"name": "@my-apps/app-a",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@my-apps/ui": "*",
"@my-apps/hooks": "*",
"@my-apps/types": "*",
"expo": "^52.0.0",
"expo-router": "^4.0.0",
"react": "^18.2.0",
"react-native": "^0.76.0"
},
"devDependencies": {
"@my-apps/config": "*"
}
}
"@my-apps/ui": "*" のようにワイルドカードを使うことで、ローカルのワークスペースパッケージが自動的に解決されます。
Metro の設定を更新
Expo アプリでモノレポを使用する際、Metro バンドラーがワークスペースパッケージを正しく解決できるよう設定が必要です。
// apps/app-a/metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { FileStore } = require('metro-cache');
const path = require('path');
// モノレポのルートディレクトリ
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// ワークスペースルートを監視対象に追加
config.watchFolders = [workspaceRoot];
// モジュール解決の設定
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// Turborepoのキャッシュと連携したMetroキャッシュ設定
config.cacheStores = [
new FileStore({
root: path.join(projectRoot, 'node_modules/.cache/metro'),
}),
];
module.exports = config;
tsconfig.json の設定
// apps/app-a/tsconfig.json
{
"extends": "@my-apps/config/typescript/expo.json",
"compilerOptions": {
"paths": {
"@my-apps/ui": ["../../packages/ui/src/index.ts"],
"@my-apps/hooks": ["../../packages/hooks/src/index.ts"],
"@my-apps/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
}
Step 6: Turborepo のリモートキャッシュで CI/CD を高速化
リモートキャッシュとは
Turborepo のリモートキャッシュは、ビルド結果をクラウドに保存し、チームメンバーや CI 環境間で共有する機能です。個人開発でも、複数の端末や GitHub Actions と共有することでビルド時間を大幅に短縮できます。
Vercel Remote Cache の設定
# Turborepo CLIでVercelにログイン
npx turbo login
# リポジトリをリモートキャッシュにリンク
npx turbo link
turbo.json にリモートキャッシュの設定を追加します。
{
"$schema": "https://turborepo.org/schema.json",
"remoteCache": {
"enabled": true,
"signature": true
},
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".expo/**"],
"cache": true,
"env": ["NODE_ENV", "EXPO_PUBLIC_API_URL"]
}
}
}
GitHub Actions との統合
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npx turbo run lint
env:
# Turborepoリモートキャッシュを使用(ビルド時間80%削減)
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- name: Type check
run: npx turbo run type-check
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Step 7: 環境変数の統合管理
モノレポでは各アプリの環境変数管理も一元化できます。
packages/config/env/index.ts
// packages/config/src/env.ts
// アプリ間で共通の環境変数スキーマを定義
interface BaseEnvSchema {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_APP_ENV: 'development' | 'staging' | 'production';
}
// 環境変数の型安全な取得と検証
export const createEnv = <T extends BaseEnvSchema>(
schema: Record<keyof T, { required: boolean; default?: string }>
): T => {
const env = {} as T;
for (const [key, config] of Object.entries(schema)) {
const value = process.env[key] ?? config.default;
if (config.required && !value) {
throw new Error(
`必須の環境変数 "${key}" が設定されていません。` +
`.env ファイルを確認してください。`
);
}
(env as Record<string, unknown>)[key] = value;
}
return env;
};
// apps/app-a/lib/env.ts
import { createEnv } from '@my-apps/config';
export const env = createEnv({
EXPO_PUBLIC_API_URL: { required: true },
EXPO_PUBLIC_APP_ENV: { required: true, default: 'development' },
EXPO_PUBLIC_STRIPE_KEY: { required: true },
});
// ビルド時に環境変数の不足を即座に検出できる
Step 8: パッケージの変更検知と影響範囲の把握
Turborepo の強力な機能の一つが、変更されたパッケージに依存するタスクのみを実行する インクリメンタルビルド です。
# packages/ui を変更した場合、ui に依存するすべてのアプリのビルドが自動的に実行される
npx turbo run build --filter="...[HEAD^1]"
# → packages/ui が変更されていれば app-a と app-b の両方がビルドされる
# → packages/hooks のみが変更されていれば、ui に依存しないアプリはスキップされる
# 特定のアプリとその依存パッケージのみをビルド
npx turbo run build --filter="@my-apps/app-a..."
# → app-a と packages/ui, packages/hooks, packages/types がビルドされる
個人開発者の視点から(実体験メモ)
まとめ
Turborepo を使ったモノレポ設計により、複数の Rork アプリ間でコードを安全かつ効率的に共有できるようになります。ここで扱うのは以下の点を詳しく解説しました。
- Turborepo の基本構造とパイプライン設定
- 共有 UI コンポーネントライブラリの構築
- TypeScript 型定義の一元管理
- 認証・ネットワーク状態などのカスタムフック共有
- 既存 Rork アプリのモノレポへの統合方法
- リモートキャッシュによる CI/CD の高速化
最初のセットアップには数時間かかりますが、一度構築すれば新機能の追加やバグ修正がすべてのアプリに一括適用され、長期的な開発速度と品質が大幅に向上します。個人開発者こそ、コードの再利用性とメンテナビリティへの投資が重要です。
モノレポ環境における CI/CD の詳細な設定については GitHub Actions × EAS Build の自動化ガイド を、大規模アプリの設計パターンについては Rork アプリのアーキテクチャパターン完全ガイド を、状態管理の高度な実践については Rork 高度な状態管理パターン もあわせてご覧ください。
個人開発の設計・アーキテクチャをさらに