Rork でアプリの画面まわりはあっという間に組めるのに、バックエンド選定で一晩悩んだ経験はないでしょうか。Firebase と Supabase、どちらも強力ですが、「Rork が生成する TypeScript コードの型安全性」と「リアルタイム性」を両立させようとすると、クライアント側のコードがじわじわと膨らんでいきます。
私自身、壁紙アプリや癒し系アプリを個人で運用していて、ユーザー同士の小さなインタラクション(お気に入り・スタンプ・コメント)を足すたびに、「もうリアルタイム DB にした方が早いのでは」と感じてきました。最近ようやく腑に落ちたのが Convex です。リアクティブな購読・自動生成される型・サーバーレス関数が1つの API にまとまっていて、Rork が吐き出すコードとの相性がとにかく良いんです。
この記事は、Rork 生成アプリに Convex を組み込んで、バックエンドを1段階賢くするまでの実装手順をまとめたものです。初回セットアップから、リアクティブ購読、ファイルアップロード、認証、スケジュール実行、本番で遭遇したハマりどころまで、動くコードで追いかけていきます。
Rork 生成アプリに Convex が刺さる5つの理由
Convex を選ぶ理由は「リアルタイム DB が欲しい」だけではありません。Rork が自動生成する TypeScript コードベースとの統合観点から並べ直すと、以下の5点に集約されます。
第一に、スキーマ → サーバー関数 → クライアント関数の型が全部つながる ことです。convex/schema.ts を書き換えると、useQuery の戻り値の型まで自動的に更新されます。Rork が tsconfig.json を strict で生成するため、Supabase のような「手動で型ファイルを生成する運用」より相性が良いです。
第二に、リアクティブ購読がデフォルト です。useQuery はそのまま使うだけで自動再購読してくれます。Firebase Firestore や Supabase Realtime だと明示的にチャネルを購読・解除する実装が必要で、画面遷移時のメモリリーク対策が地味に面倒です。
第三に、楽観的更新が1行で書けます 。useMutation(api.tasks.toggle).withOptimisticUpdate(...) の形で、UI 即応性と整合性を両立できます。個人開発者には正直ありがたい設計です。
第四に、ファイルストレージと関数が同じプロジェクトに同居 します。S3 + Lambda のような別サービス連携を組まずに、ctx.storage.generateUploadUrl() でアップロード URL を発行して返すだけ。これは Rork のプロンプト指示にもそのまま乗せられます。
第五に、Clerk との統合が公式にサポートされている こと。Rork + Clerk のソーシャルログイン を使っているなら、Convex はその認証情報をそのまま関数内で ctx.auth.getUserIdentity() として受け取れます。
プロジェクトへのセットアップ
Rork で生成したプロジェクトに Convex を入れる手順は、思っているより簡単です。ターミナルで以下のコマンドを実行します。
# 既存の Rork プロジェクトのルートで実行
npx convex dev
# 初回は Convex アカウントのログイン、プロジェクト作成が走ります
# 完了すると convex/ ディレクトリと .env.local に NEXT_PUBLIC_CONVEX_URL が追記されます
npm install convex
次に、React Native アプリ全体を ConvexProvider でラップします。Expo Router を使っている Rork 生成プロジェクトなら、app/_layout.tsx に組み込むのが自然です。
// app/_layout.tsx(抜粋)
import { ConvexProvider, ConvexReactClient } from "convex/react" ;
import { Stack } from "expo-router" ;
// 環境変数は app.json の extra 経由で読むか、EXPO_PUBLIC_ プレフィックスを使います
const convex = new ConvexReactClient (process.env. EXPO_PUBLIC_CONVEX_URL ! , {
unsavedChangesWarning: false , // React Native では不要なので無効化
});
export default function RootLayout () {
return (
< ConvexProvider client = { convex } >
< Stack screenOptions = { { headerShown: false } } />
</ ConvexProvider >
);
}
ここで unsavedChangesWarning: false を入れているのが、地味に重要です。Web の離脱警告を前提にしたデフォルト挙動が React Native 環境で警告を吐くので、最初に止めておきます。
スキーマ駆動でデータモデルを書く
Convex は「スキーマを書くとクライアントの型まで降ってくる」のが売りなので、まずは convex/schema.ts から丁寧に設計します。壁紙アプリの「お気に入り」機能を例にしましょう。
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server" ;
import { v } from "convex/values" ;
export default defineSchema ({
wallpapers: defineTable ({
title: v. string (),
category: v. string (),
imageStorageId: v. id ( "_storage" ),
// 公開 URL は都度生成するので保存しない
createdAt: v. number (),
}). index ( "by_category" , [ "category" ]) ,
favorites: defineTable ({
userId: v. string (), // Clerk の subject をそのまま使う
wallpaperId: v. id ( "wallpapers" ),
savedAt: v. number (),
})
. index ( "by_user" , [ "userId" ])
. index ( "by_user_wallpaper" , [ "userId" , "wallpaperId" ]) ,
}) ;
ポイントは、インデックスを「実際に叩くクエリの順」で定義しておくこと です。Convex は明示的なインデックスがあるクエリだけが O(log n) で動きます。後から追加するとデータ量によっては数秒のマイグレーションが走るので、初期設計で押さえておくのが吉です。
クエリとミューテーション:最小の実装
「あるユーザーのお気に入り一覧を取得する」と「お気に入りをトグルする」を例に、サーバー関数を書きます。
// convex/favorites.ts
import { query, mutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const list = query ({
args: { userId: v. string () },
handler : async ( ctx , { userId }) => {
const favorites = await ctx.db
. query ( "favorites" )
. withIndex ( "by_user" , ( q ) => q. eq ( "userId" , userId))
. order ( "desc" )
. collect ();
// お気に入りに紐づく壁紙を一緒に返す
return Promise . all (
favorites. map ( async ( f ) => ({
... f,
wallpaper: await ctx.db. get (f.wallpaperId),
}))
);
},
});
export const toggle = mutation ({
args: {
userId: v. string (),
wallpaperId: v. id ( "wallpapers" ),
},
handler : async ( ctx , { userId , wallpaperId }) => {
const existing = await ctx.db
. query ( "favorites" )
. withIndex ( "by_user_wallpaper" , ( q ) =>
q. eq ( "userId" , userId). eq ( "wallpaperId" , wallpaperId)
)
. unique ();
if (existing) {
await ctx.db. delete (existing._id);
return { state: "removed" as const };
}
const id = await ctx.db. insert ( "favorites" , {
userId,
wallpaperId,
savedAt: Date. now (),
});
return { state: "added" as const , id };
},
});
このファイルを保存するだけで、convex/_generated/api.d.ts が自動更新され、クライアントから api.favorites.list と api.favorites.toggle が型付きで呼べるようになります。Rork が書いた画面コードを手で書き換える必要はほぼありません。
クライアント側:リアクティブに UI を同期する
React Native 側から使うときは、useQuery と useMutation を素直に並べるだけです。
// app/(tabs)/favorites.tsx(抜粋)
import { useQuery, useMutation } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
import { useUser } from "@clerk/clerk-expo" ;
export default function FavoritesScreen () {
const { user } = useUser ();
const userId = user?.id ?? "guest" ;
// データが更新されると自動で再レンダリングされます
const favorites = useQuery (api.favorites.list, { userId });
const toggle = useMutation (api.favorites.toggle). withOptimisticUpdate (
( localStore , args ) => {
// 楽観的更新:通信待ちでも UI は即座に変わる
const current = localStore. getQuery (api.favorites.list, {
userId: args.userId,
});
if ( ! current) return ;
const existingIndex = current. findIndex (
( f ) => f.wallpaperId === args.wallpaperId
);
if (existingIndex >= 0 ) {
localStore. setQuery (
api.favorites.list,
{ userId: args.userId },
current. filter (( _ , i ) => i !== existingIndex)
);
}
}
);
if (favorites === undefined ) {
return < LoadingView />;
}
// ... 以降は生成された UI と同じ
}
useQuery の戻り値は undefined(ローディング中)/ 配列(取得済み)のどちらかです。この2状態を扱うだけで「手でローディング state を管理する」というお決まりのコードが不要になります。個人的には、この点がチームを組まない一人開発でいちばん効いています。
ファイルアップロード:Convex Storage で完結させる
ユーザー投稿のスクリーンショットや写真を受け取るとき、S3 バケットを自前で設計する必要はありません。Convex の Storage は、「署名付き URL を関数で発行 → クライアントで直接アップロード → ストレージ ID を DB に保存」という流れに乗るだけです。
// convex/uploads.ts
import { mutation } from "./_generated/server" ;
import { v } from "convex/values" ;
// アップロード URL を発行(10分間有効)
export const generateUploadUrl = mutation ({
handler : async ( ctx ) => {
return await ctx.storage. generateUploadUrl ();
},
});
// 画像メタデータを保存
export const saveUploadedWallpaper = mutation ({
args: {
storageId: v. id ( "_storage" ),
title: v. string (),
category: v. string (),
},
handler : async ( ctx , args ) => {
return await ctx.db. insert ( "wallpapers" , {
title: args.title,
category: args.category,
imageStorageId: args.storageId,
createdAt: Date. now (),
});
},
});
クライアント側は expo-image-picker と組み合わせれば、20行ほどでアップロード UI が完成します。
// hooks/useWallpaperUpload.ts
import * as ImagePicker from "expo-image-picker" ;
import { useMutation } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
export function useWallpaperUpload () {
const generateUploadUrl = useMutation (api.uploads.generateUploadUrl);
const saveWallpaper = useMutation (api.uploads.saveUploadedWallpaper);
const upload = async ( category : string ) => {
const result = await ImagePicker. launchImageLibraryAsync ({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9 ,
});
if (result.canceled) return null ;
const asset = result.assets[ 0 ];
const uploadUrl = await generateUploadUrl ();
// 署名付き URL に直接 PUT
const response = await fetch (uploadUrl, {
method: "POST" ,
headers: { "Content-Type" : asset.mimeType ?? "image/jpeg" },
body: await ( await fetch (asset.uri)). blob (),
});
const { storageId } = await response. json ();
// メタデータを保存
return await saveWallpaper ({
storageId,
title: asset.fileName ?? "untitled" ,
category,
});
};
return { upload };
}
期待動作としては、ユーザーが画像を選んでから DB に反映されるまでがほぼ1〜2秒、回線が遅いときでも進捗を自前で管理できます。fetch(asset.uri).blob() の部分は React Native の Fetch API の癖を踏まえたお作法で、これを Buffer で無理に整形するとメモリが一気に跳ねるので注意してください。
Clerk と組み合わせた認証
Rork で Clerk を入れているなら、Convex 側との紐付けは convex/auth.config.ts に1ファイル足すだけです。
// convex/auth.config.ts
export default {
providers: [
{
domain: "https://your-clerk-instance.clerk.accounts.dev" , // Clerk の Frontend API URL
applicationID: "convex" ,
},
] ,
} ;
Clerk 側でも JWT テンプレート convex を作成しておく必要があります。設定後は、サーバー関数内で ctx.auth.getUserIdentity() を呼ぶだけで認証済みユーザーの情報が取れます。
// convex/favorites.ts の toggle を認証済みに置き換える
export const toggleSecure = mutation ({
args: { wallpaperId: v. id ( "wallpapers" ) },
handler : async ( ctx , { wallpaperId }) => {
const identity = await ctx.auth. getUserIdentity ();
if ( ! identity) throw new Error ( "ログインが必要です" );
// identity.subject が Clerk のユーザー ID
// ...以降は先ほどと同じロジック
},
});
Supabase の RLS のように「DB 側に認可ルールを書く」発想ではなく、関数の入口で明示的に identity を確認する のが Convex 流です。個人開発では、この「分岐が1か所に集まる」設計の方が読み返しやすいと感じます。
バックグラウンドジョブとスケジュール
「毎日2時にお気に入り数トップの壁紙を集計して、ピックアップテーブルを更新する」といったジョブも、Convex の内側で完結します。
// convex/crons.ts
import { cronJobs } from "convex/server" ;
import { internal } from "./_generated/api" ;
const crons = cronJobs ();
crons. daily (
"daily-wallpaper-pickup" ,
{ hourUTC: 17 , minuteUTC: 0 }, // JST 2:00
internal.pickup.recalculate
);
export default crons;
// convex/pickup.ts
import { internalMutation } from "./_generated/server" ;
export const recalculate = internalMutation ({
handler : async ( ctx ) => {
const wallpapers = await ctx.db. query ( "wallpapers" ). collect ();
for ( const w of wallpapers) {
const count = (
await ctx.db
. query ( "favorites" )
. withIndex ( "by_user_wallpaper" , ( q ) =>
q. eq ( "userId" , "*" as any ). eq ( "wallpaperId" , w._id) // 全ユーザー分
)
. collect ()
). length ;
// スコアを別テーブルに書き出すなど
console. log ( `[pickup] ${ w . title }: ${ count } favorites` );
}
},
});
internalMutation はクライアントから叩けない関数で、Cron 経由や他の関数からのみ呼び出せます。外部に漏らしたくないバッチ処理はすべてこちらに寄せると、セキュリティ面の見通しが立ちやすくなります。
本番運用でハマりやすい落とし穴
ここからは、実際に運用してみないと気づきにくい3つのハマりどころを共有します。
1つ目は、スキーマ変更時の自動マイグレーション待ち です。defineTable のフィールド型を変えると、Convex は既存ドキュメントをバリデーションしにかかります。レコードが数万件あると、npx convex deploy が数十秒〜分単位で止まります。対処は、一度 v.optional(...) で新旧両対応のスキーマを作り、データ移行用の internalMutation を走らせたあとで、旧フィールドを消す2段階デプロイを取ること。これは本番直前に焦ると怖いので、開発時から意識しておくのが吉です。
2つ目は、楽観的更新と同時ミューテーションの衝突 です。withOptimisticUpdate を複数画面で同じキーに対して走らせると、ローカルストアの中間状態が食い違い、UI がチラつくことがあります。対策は、楽観的更新のロジックを lib/optimistic.ts のような1つのヘルパーに寄せて、画面コンポーネントから直接触らせないことです。Rork が画面ごとに同じロジックを展開しがちなので、生成後のリファクタ対象として優先度が高いです。
3つ目は、レート制限と冪等性の設計漏れ です。Convex はデフォルトでは関数実行回数に無料枠の上限があり、普通のチャットアプリでも「1秒に10回同じ toggle を叩く操作」が見つかると簡単に跳ね返されます。運用1年で一度刺さったので、重要なミューテーションには必ず ctx.db.query(...).filter(q => q.gt(q.field("savedAt"), Date.now() - 1000)) のような冪等チェックを入れています。ユーザーが連打しがちな「いいね」「購入」「通知送信」は特に要注意です。
本番に向けた仕上げ:エラーハンドリングと監視
最後に、コピペで使えるエラーハンドリングパターンを置いておきます。Convex の関数が throw したエラーはクライアントに ConvexError として届くので、ユーザー向けメッセージはサーバー側で整形してしまうのが楽です。
// convex/lib/errors.ts
import { ConvexError } from "convex/values" ;
export function requireAuth ( identity : { subject ?: string } | null ) {
if ( ! identity?.subject) {
throw new ConvexError ({
code: "UNAUTHENTICATED" ,
message: "ログインが必要です" ,
});
}
return identity.subject;
}
export function requireOwnership < T extends { userId : string }>(
doc : T | null ,
userId : string
) {
if ( ! doc) throw new ConvexError ({ code: "NOT_FOUND" , message: "見つかりません" });
if (doc.userId !== userId)
throw new ConvexError ({ code: "FORBIDDEN" , message: "権限がありません" });
return doc;
}
クライアント側では useMutation の戻り値を try/catch で包み、error.data.code を見てトーストを出し分けます。この形にしておくと、Sentry などに送るメッセージと、画面上に出すメッセージを分離しやすくなります。
監視まわりは、Convex のダッシュボードから関数ごとの P95 レイテンシと失敗率が見られます。個人開発でも、「P95 が200msを超える関数」「エラー率が2%を超えた日」を週に1度チェックするだけで、手遅れになる前に改善できます。詳細な監視パターンは Rork × Sentry クラッシュ監視の導入ガイドも併せて読むと、全体像が掴めます。
個人開発者の視点から(実体験メモ)
次の一歩
ここまでくれば、Rork 生成プロジェクトに Convex を組み込むための「最初の1アプリ」は作れる状態です。私の場合、壁紙アプリに Convex を入れてから、ユーザー別のお気に入り同期・コメント・日次ピックアップが一気に動くようになり、Supabase + 自作 Worker の構成より保守する部分がずいぶん減りました。
まずは手元のサンプルアプリで convex/schema.ts を1テーブルだけ書いて、useQuery でリストを引くところまで試してみるのがおすすめです。そこから先は、本記事の型(楽観的更新・ファイルアップロード・認証・Cron)を順番に積み上げていけば、個人開発の範囲で十分に戦えるバックエンドになります。
Convex のような新しい抽象を扱うときほど、REST/HTTP の原則に立ち返ると意思決定が早くなります。
関連して、既存の Supabase 実装と併用する判断軸については Rork + Supabase 認証とリアルタイム同期の実践ガイド を参照してください。また、Convex を選ぶ前に Firebase と Supabase の違いを整理したい場合は Rork で Firebase / Supabase の認証を使い分ける考え方 が出発点として便利です。