「iOS と Android に加えて Web 版も出したい」という要望は多くのアプリ開発者が持っています。しかし通常、Webアプリのために Next.js などの別リポジトリを管理する必要があり、機能追加のたびに3つのコードベースを更新しなければならないのが実情です。
Expo Router v5 の Web サポートを使えば、Rork Max で生成した React Native コードをほぼそのまま Web 向けにもビルドできます。ここで扱うのはiOS・Android・Web を1つのコードベースで管理するための設計パターン、プラットフォーム固有の最適化、SEO対応、そして Cloudflare Pages へのデプロイまでを体系的に解説します。
Expo Router Web の仕組みを理解する
Expo Router は React Navigation をベースにしたファイルシステムルーティングライブラリで、app/ ディレクトリの構造が自動的にルート定義になります。
Web ビルド時は react-native-web が各コンポーネントを DOM 要素にマッピングし、ブラウザで動作するアプリを生成します。React Native の <View> は <div> に、<Text> は <span> にマッピングされます。
app/
├── (tabs)/
│ ├── _layout.tsx ← タブナビゲーション
│ ├── index.tsx ← /
│ ├── explore.tsx ← /explore
│ └── settings.tsx ← /settings
├── articles/
│ ├── [id].tsx ← /articles/:id
│ └── index.tsx ← /articles
└── _layout.tsx ← ルートレイアウト
Web では上記がそのまま URL 構造になります。
プロジェクトの Web 対応セットアップ
app.json の設定
{
"expo" : {
"name" : "MyApp" ,
"slug" : "my-app" ,
"platforms" : [ "ios" , "android" , "web" ],
"web" : {
"bundler" : "metro" ,
"output" : "static" ,
"favicon" : "./assets/favicon.png"
},
"experiments" : {
"typedRoutes" : true
}
}
}
"output": "static" にすることで、ビルド時に静的HTMLファイルを生成します。SEOが重要なアプリには static モードが推奨です。
必要な依存関係の確認
# Rork Max が自動でインストールしている場合がほとんどですが、念のず確認
npx expo install react-native-web react-dom @expo/metro-runtime
Web ビルドの実行
# 開発サーバー起動(ブラウザでも確認可能)
npx expo start --web
# 本番用静的ビルド
npx expo export --platform web
プラットフォーム別 UI の分岐設計
Web とネイティブでは UX の期待値が異なります。プラットフォーム分岐を使って、各環境で最適な体験を提供しましょう。
Platform.select による分岐
import { Platform, StyleSheet } from 'react-native' ;
const styles = StyleSheet. create ({
container: {
... Platform. select ({
web: {
maxWidth: 768 ,
marginHorizontal: 'auto' ,
paddingHorizontal: 24 ,
},
default: {
flex: 1 ,
paddingHorizontal: 16 ,
},
}),
},
header: {
... Platform. select ({
web: {
position: 'sticky' ,
top: 0 ,
zIndex: 100 ,
backdropFilter: 'blur(12px)' ,
},
default: {
// ネイティブはデフォルトスタイル
},
}),
},
});
プラットフォーム固有コンポーネントの分割
ファイル名にプラットフォームサフィックスをつけることで、Metro Bundler が自動で切り替えます。
components/
├── VideoPlayer.tsx ← デフォルト(全プラットフォーム共通)
├── VideoPlayer.web.tsx ← Web専用(<video>タグを使用)
└── VideoPlayer.native.tsx ← iOS/Android専用
// VideoPlayer.web.tsx
export const VideoPlayer = ({ uri } : { uri : string }) => (
< video
src = {uri}
controls
style = {{ width : '100%' , maxHeight : 480 , borderRadius : 8 }}
/>
);
// VideoPlayer.native.tsx
import { Video, ResizeMode } from 'expo-av' ;
export const VideoPlayer = ({ uri } : { uri : string }) => (
< Video
source = {{ uri }}
resizeMode = {ResizeMode.CONTAIN}
useNativeControls
style = {{ width : '100%' , height : 240 }}
/>
);
Navigation の Web対応
Expo Router は Web では標準的なブラウザのURL履歴(History API)を使用します。<Link> コンポーネントは自動的に <a> タグにマッピングされるため、SEOとアクセシビリティも確保されます。
import { Link, useRouter } from 'expo-router' ;
// Link コンポーネント(Web では <a> として出力される)
< Link href = "/articles/my-article" asChild >
< TouchableOpacity >
< Text >記事を読む </ Text >
</ TouchableOpacity >
</ Link >
// programmatic navigation
const router = useRouter ();
router. push ( '/articles/my-article' );
Web 向け SEO 対応
Expo Router の expo-router/head を使って、ページごとにメタタグを設定できます。
// app/articles/[id].tsx
import { Stack } from 'expo-router' ;
import Head from 'expo-router/head' ;
import { Platform } from 'react-native' ;
export default function ArticleScreen () {
const { id } = useLocalSearchParams ();
const article = useArticle (id as string );
return (
<>
{ Platform . OS === ' web ' && (
< Head >
< title >{ article . title } | My App </ title >
< meta name = "description" content = {article.description} />
< meta property = "og:title" content = {article.title} />
< meta property = "og:description" content = {article.description} />
< meta property = "og:image" content = {article.coverImage} />
< meta property = "og:type" content = "article" />
< meta name = "twitter:card" content = "summary_large_image" />
< link
rel = "canonical"
href = { `https://myapp.com/articles/${ id }` }
/>
</ Head >
)}
< Stack.Screen
options = {{
title : article.title,
headerBackTitle : '記事一覧' ,
}}
/>
< ArticleContent article = {article} />
</>
);
}
サイトマップの自動生成
静的ビルドの場合、全ページのサイトマップを生成してSEOを強化します。
// scripts/generate-sitemap.ts
import { writeFileSync } from 'fs' ;
const BASE_URL = 'https://myapp.com' ;
const generateSitemap = async () => {
const articles = await fetchAllArticles ();
const staticRoutes = [ '/' , '/explore' , '/settings' ];
const dynamicRoutes = articles. map ( a => `/articles/${ a . id }` );
const allRoutes = [ ... staticRoutes, ... dynamicRoutes];
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${ allRoutes
. map (
route => ` <url>
<loc>${ BASE_URL }${ route }</loc>
<lastmod>${ new Date (). toISOString (). split ( 'T' )[ 0 ] }</lastmod>
<changefreq>weekly</changefreq>
<priority>${ route === '/' ? '1.0' : '0.8'}</priority>
</url>`
)
. join ( ' \n ' ) }
</urlset>` ;
writeFileSync ( 'dist/sitemap.xml' , sitemap);
console. log ( `Sitemap generated: ${ allRoutes . length } URLs` );
};
generateSitemap ();
Cloudflare Pages へのデプロイ
デプロイコマンド
# ビルド
npx expo export --platform web
# dist/ フォルダにHTMLファイルが生成されます
# Wrangler でデプロイ
npx wrangler pages deploy dist/ --project-name my-app-web
Cloudflare Pages の設定
Cloudflare Pages ダッシュボードから以下を設定します:
ビルドコマンド : npx expo export --platform web
ビルド出力ディレクトリ : dist
Node.js バージョン : 20.x
SPA ルーティングの設定
Expo Router の Web ビルドは SPA(Single Page Application)として動作します。直接URLアクセス時に404が発生しないよう、dist/_redirects ファイルを作成します。
/* /index.html 200
カスタムドメインと SSL
Cloudflare Pages はカスタムドメインと自動SSL(Let's Encrypt)を無料で提供します。ダッシュボードの「Custom Domains」からドメインを追加し、DNS設定を行うだけで完了します。
iOS・Android・Web の一元 CI/CD
GitHub Actions による自動ビルド・デプロイ
# .github/workflows/deploy.yml
name : Build & Deploy
on :
push :
branches : [ main ]
jobs :
# iOS / Android は EAS Build
mobile-build :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
- run : npm install
- uses : expo/expo-github-action@v8
with :
eas-version : latest
token : ${{ secrets.EXPO_TOKEN }}
- run : eas build --platform all --non-interactive
# Web は Cloudflare Pages
web-deploy :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
- run : npm install
- run : npx expo export --platform web
- uses : cloudflare/wrangler-action@v3
with :
apiToken : ${{ secrets.CLOUDFLARE_API_TOKEN }}
command : pages deploy dist/ --project-name my-app-web
このワークフローで、main ブランチへのpushが iOS/Android のビルドキューに追加されると同時に、Web版の最新状態が Cloudflare Pages に自動デプロイされます。
Web パフォーマンスの最適化
画像の Web 最適化
import { Image } from 'expo-image' ;
import { Platform } from 'react-native' ;
// expo-image は Web でも自動的に WebP/AVIF を使用し、
// Lazy Loading と Blur Placeholder をサポート
const OptimizedImage = ({ uri , ... props } : ImageProps ) => (
< Image
source = {{ uri }}
placeholder = {{ blurhash : 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
contentFit = "cover"
transition = { 200 }
{ ... props }
style = {[
props.style,
Platform. OS === 'web' && { maxWidth: '100%' }
]}
/>
);
フォントの最適化
Web では CSS font-display: swap を使って、カスタムフォントの読み込み中にテキストが見えない問題を防ぎます。
// app/_layout.tsx
import { useFonts } from 'expo-font' ;
import * as SplashScreen from 'expo-splash-screen' ;
SplashScreen. preventAutoHideAsync ();
export default function RootLayout () {
const [ loaded ] = useFonts ({
'SpaceMono-Regular' : require ( '../assets/fonts/SpaceMono-Regular.ttf' ),
});
useEffect (() => {
if (loaded) SplashScreen. hideAsync ();
}, [loaded]);
// Web では、フォント未ロード中も fallback フォントでコンテンツを表示する
if ( ! loaded && Platform. OS !== 'web' ) return null ;
return < Slot />;
}
よくある問題とその解決策
window is not defined エラー
React Native のコード内でブラウザ固有のAPIを使っている場合に発生します。Platform.OS === 'web' で条件分岐するか、typeof window !== 'undefined' でチェックします。
スクロールの挙動が iOS/Android と異なる
ScrollView の contentContainerStyle に { flexGrow: 1 } を追加すると、Web でも期待通りにスクロールします。
AsyncStorage が Web で動作しない
@react-native-async-storage/async-storage は Web でも動作しますが、localStorage のラッパーとして機能します。プライベートブラウジングや容量制限に注意が必要です。
Gesture Handler が Web で効かない
react-native-gesture-handler の Web サポートはまだ限定的です。Webのみ onPress + onMouseOver などのイベントハンドラで代替するとよいでしょう。
個人開発者の視点から(実体験メモ)
全体を振り返って:3プラットフォーム同時展開の価値
Expo Router の Web サポートを活用すると、iOS・Android で動くアプリをほぼそのまま Web にも展開できます。完全なコード共有率は現実的に60〜80%程度ですが、それでも3つの独立したコードベースを管理するコストに比べれば大幅な効率化です。
Rork Max で生成したアプリのベースは、すでにこのアーキテクチャに対応できる構造になっています。本記事のガイドに従って Web 対応を追加することで、ユーザーリーチを大きく広げながら、開発コストを最小化できます。iOS・Android に加えて Web でも見つけてもらえるアプリを——ぜひ挑戦してみてください。