取り組みの背景:なぜ型安全なバックエンドが個人開発者に必要なのか
Rork Max でアプリを開発していると、バックエンドAPIとの連携で思わぬバグに悩まされることがあります。APIのレスポンス型が変わったことに気づかず、モバイルアプリがクラッシュします。フィールド名のタイポが実行時エラーになります。こうした問題は、開発規模が大きくなるほど深刻さを増します。
tRPC(TypeScript Remote Procedure Call)は、クライアントとサーバーの間で型定義を完全に共有する画期的なフレームワークです。Cloudflare Workers のエッジ環境と組み合わせることで、世界中どこからでも低レイテンシで動作する型安全なAPIバックエンドを、個人開発者でも構築できます。
tRPC と Cloudflare Workers の組み合わせが強力な理由
tRPC の核心:型の自動伝播
従来の REST API 開発では、バックエンドとフロントエンドで同じ型定義を別々に管理することが一般的でしました。しかし tRPC では、サーバー側で定義した型が自動的にクライアント側に伝わります。
// サーバー側(Cloudflare Workers)
export const appRouter = router ({
getUserProfile: publicProcedure
. input (z. object ({ userId: z. string (). uuid () }))
. query ( async ({ input , ctx }) => {
// 戻り値の型が自動的にクライアントに伝わる
return {
id: input.userId,
name: 'Masaki' ,
email: 'user@example.com' ,
createdAt: new Date (). toISOString (),
};
}),
});
// AppRouter 型をエクスポートしてクライアントと共有
export type AppRouter = typeof appRouter;
このように定義すると、Rork Max 側のコードでは補完が効き、型エラーが即座に検出されます。
Cloudflare Workers のエッジ実行の優位性
Cloudflare Workers は世界 300 以上のデータセンターで動作するエッジコンピューティング環境です。ユーザーに最も近いデータセンターでコードが実行されるため、従来のサーバーレス関数と比べてコールドスタートがなく、レイテンシが極めて低い点が特徴です。
個人開発者にとっての現実的なメリットとして、無料プランで月 10 万リクエストまで処理でき、従量課金のコストも非常に安価です。Rork Max アプリのバックエンドとして、スタートアップのコストを最小限に抑えながら本番グレードのインフラを利用できます。
環境構築:プロジェクト全体の構成設計
モノレポ構成の採用
型を共有するために、フロントエンド(Rork Max)とバックエンド(Cloudflare Workers)を同一リポジトリで管理するモノレポ構成を推奨します。
my-rork-app/
├── apps/
│ └── mobile/ ← Rork Max プロジェクト
│ ├── app/
│ ├── package.json
│ └── ...
├── packages/
│ └── api/ ← tRPC バックエンド(Cloudflare Workers)
│ ├── src/
│ │ ├── index.ts ← Workers エントリーポイント
│ │ ├── router/ ← tRPC ルーター定義
│ │ ├── middleware/ ← 認証・ログ等のミドルウェア
│ │ └── db/ ← Supabase / D1 連携
│ ├── wrangler.toml
│ └── package.json
├── package.json ← ワークスペースルート
└── pnpm-workspace.yaml
依存パッケージのインストール
# バックエンドパッケージ
cd packages/api
pnpm add @trpc/server hono @hono/trpc-server zod
pnpm add -D wrangler @cloudflare/workers-types typescript
# Rork Max(モバイル)側
cd apps/mobile
pnpm add @trpc/client @trpc/react-query @tanstack/react-query zod
wrangler.toml の設定
# packages/api/wrangler.toml
name = "my-rork-api"
main = "src/index.ts"
compatibility_date = "2026-03-01"
compatibility_flags = [ "nodejs_compat" ]
[ vars ]
ENVIRONMENT = "production"
[[ kv_namespaces ]]
binding = "CACHE"
id = "your-kv-namespace-id"
[[ d1_databases ]]
binding = "DB"
database_name = "my-rork-db"
database_id = "your-database-id"
tRPC ルーターの設計と実装
コアルーターの構築
// packages/api/src/router/index.ts
import { router } from '../trpc' ;
import { authRouter } from './auth' ;
import { userRouter } from './user' ;
import { contentRouter } from './content' ;
export const appRouter = router ({
auth: authRouter,
user: userRouter,
content: contentRouter,
});
export type AppRouter = typeof appRouter;
// packages/api/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server' ;
import { z } from 'zod' ;
import type { HonoContext } from './types' ;
interface Context {
env : Env ;
userId ?: string ;
}
const t = initTRPC. context < Context >(). create ({
errorFormatter ({ shape , error }) {
return {
... shape,
data: {
... shape.data,
// クライアントに渡すエラー情報を制御
zodError:
error.cause instanceof z . ZodError
? error.cause. flatten ()
: null ,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// 認証済みユーザーのみアクセス可能なプロシージャ
export const protectedProcedure = t.procedure. use (({ ctx , next }) => {
if ( ! ctx.userId) {
throw new TRPCError ({
code: 'UNAUTHORIZED' ,
message: '認証が必要です' ,
});
}
return next ({ ctx: { ... ctx, userId: ctx.userId } });
});
ユーザールーターの実装例
// packages/api/src/router/user.ts
import { z } from 'zod' ;
import { router, protectedProcedure, publicProcedure } from '../trpc' ;
import { TRPCError } from '@trpc/server' ;
const UserSchema = z. object ({
id: z. string (). uuid (),
name: z. string (). min ( 1 ). max ( 50 ),
email: z. string (). email (),
bio: z. string (). max ( 200 ). optional (),
avatarUrl: z. string (). url (). optional (),
createdAt: z. string (),
});
export const userRouter = router ({
// 公開エンドポイント:ユーザープロフィール取得
getProfile: publicProcedure
. input (z. object ({ userId: z. string (). uuid () }))
. output (UserSchema)
. query ( async ({ input , ctx }) => {
const result = await ctx.env. DB . prepare (
'SELECT * FROM users WHERE id = ?'
). bind (input.userId). first ();
if ( ! result) {
throw new TRPCError ({
code: 'NOT_FOUND' ,
message: 'ユーザーが見つかりません' ,
});
}
return result as z . infer < typeof UserSchema>;
}),
// 認証必須:自分のプロフィール更新
updateProfile: protectedProcedure
. input (z. object ({
name: z. string (). min ( 1 ). max ( 50 ). optional (),
bio: z. string (). max ( 200 ). optional (),
}))
. mutation ( async ({ input , ctx }) => {
const updates : string [] = [];
const values : unknown [] = [];
if (input.name) {
updates. push ( 'name = ?' );
values. push (input.name);
}
if (input.bio !== undefined ) {
updates. push ( 'bio = ?' );
values. push (input.bio);
}
if (updates. length === 0 ) {
throw new TRPCError ({
code: 'BAD_REQUEST' ,
message: '更新するフィールドがありません' ,
});
}
values. push ( new Date (). toISOString (), ctx.userId);
await ctx.env. DB . prepare (
`UPDATE users SET ${ updates . join ( ', ' ) }, updated_at = ? WHERE id = ?`
). bind ( ... values). run ();
return { success: true };
}),
});
Cloudflare Workers エントリーポイントの実装
Hono との統合
Hono は Cloudflare Workers 上で動作する軽量 Web フレームワークで、tRPC との統合が公式にサポートされています。
// packages/api/src/index.ts
import { Hono } from 'hono' ;
import { cors } from 'hono/cors' ;
import { logger } from 'hono/logger' ;
import { trpcServer } from '@hono/trpc-server' ;
import { appRouter } from './router' ;
import { createContext } from './context' ;
// Cloudflare Workers の環境変数型定義
export interface Env {
DB : D1Database ;
CACHE : KVNamespace ;
JWT_SECRET : string ;
ENVIRONMENT : string ;
}
const app = new Hono <{ Bindings : Env }>();
// CORSの設定(Rork Max アプリのドメインを許可)
app. use (
'/trpc/*' ,
cors ({
origin: [
'https://your-rork-app.com' ,
'exp://localhost:8081' , // Expo 開発時
],
allowMethods: [ 'GET' , 'POST' , 'OPTIONS' ],
allowHeaders: [ 'Content-Type' , 'Authorization' ],
credentials: true ,
})
);
app. use ( '/trpc/*' , logger ());
// tRPC ハンドラーのマウント
app. use (
'/trpc/*' ,
trpcServer ({
router: appRouter,
createContext : async ( opts , c ) => createContext (opts, c),
})
);
// ヘルスチェックエンドポイント
app. get ( '/health' , ( c ) => {
return c. json ({
status: 'ok' ,
timestamp: new Date (). toISOString (),
environment: c.env. ENVIRONMENT ,
});
});
export default app;
コンテキスト生成と JWT 認証
// packages/api/src/context.ts
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' ;
import type { Context } from 'hono' ;
import type { Env } from './index' ;
import * as jose from 'jose' ;
export async function createContext (
opts : FetchCreateContextFnOptions ,
c : Context <{ Bindings : Env }>
) {
const authHeader = opts.req.headers. get ( 'Authorization' );
let userId : string | undefined ;
if (authHeader?. startsWith ( 'Bearer ' )) {
const token = authHeader. slice ( 7 );
try {
const secret = new TextEncoder (). encode (c.env. JWT_SECRET );
const { payload } = await jose. jwtVerify (token, secret);
userId = payload.sub as string ;
} catch {
// トークンが無効な場合は userId を undefined のまま
}
}
return {
env: c.env,
userId,
};
}
Rork Max 側のクライアント実装
tRPC クライアントの初期化
// apps/mobile/src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query' ;
import { httpBatchLink } from '@trpc/client' ;
import type { AppRouter } from '../../../packages/api/src/router' ;
// AppRouter 型をインポートして完全な型安全を実現
export const trpc = createTRPCReact < AppRouter >();
export function createTRPCClient ( getToken : () => string | null ) {
return {
links: [
httpBatchLink ({
url: process.env. EXPO_PUBLIC_API_URL + '/trpc' ,
headers () {
const token = getToken ();
return token
? { Authorization: `Bearer ${ token }` }
: {};
},
}),
],
};
}
React Query プロバイダーのセットアップ
// apps/mobile/src/providers/TRPCProvider.tsx
import { useState } from 'react' ;
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' ;
import { trpc, createTRPCClient } from '../lib/trpc' ;
import { useAuth } from '../hooks/useAuth' ;
export function TRPCProvider ({ children } : { children : React . ReactNode }) {
const { getToken } = useAuth ();
const [ queryClient ] = useState (
() =>
new QueryClient ({
defaultOptions: {
queries: {
retry: 2 ,
staleTime: 5 * 60 * 1000 , // 5分間キャッシュ
},
},
})
);
const [ trpcClient ] = useState (() =>
trpc. createClient ( createTRPCClient (getToken))
);
return (
< trpc.Provider client = {trpcClient} queryClient = {queryClient} >
< QueryClientProvider client = {queryClient} >
{ children }
</ QueryClientProvider >
</ trpc.Provider >
);
}
コンポーネントでのデータフェッチ
// apps/mobile/src/screens/ProfileScreen.tsx
import { View, Text, ActivityIndicator } from 'react-native' ;
import { trpc } from '../lib/trpc' ;
export function ProfileScreen ({ userId } : { userId : string }) {
// 型が完全に補完される。プロパティ名のミスはコンパイル時に検出
const { data : profile , isLoading , error } = trpc.user.getProfile. useQuery (
{ userId },
{
enabled: !! userId,
}
);
if (isLoading) return < ActivityIndicator />;
if (error) {
// tRPC のエラーコードをそのまま使える
return (
< Text >
{ error . data ?. code === ' NOT_FOUND '
? ' ユーザーが見つかりません '
: ' エラーが発生しました '}
</ Text >
);
}
return (
< View >
{ /* profile の型が AppRouter から自動推論される */ }
< Text >{profile.name} </ Text >
< Text >{profile.email} </ Text >
{ profile . bio && < Text >{ profile . bio }</ Text >}
</ View >
);
}
ミューテーションの場合も同様に型安全です。
// プロフィール更新ミューテーション
const updateProfile = trpc.user.updateProfile. useMutation ({
onSuccess : () => {
// キャッシュを無効化して最新データを再取得
utils.user.getProfile. invalidate ({ userId: currentUserId });
},
onError : ( error ) => {
if (error.data?.zodError) {
// Zod バリデーションエラーを詳細に処理できる
console. error (error.data.zodError.fieldErrors);
}
},
});
// 呼び出し時も型安全
updateProfile. mutate ({ name: '新しい名前' , bio: '自己紹介文' });
本番運用のための高度な設計パターン
Cloudflare D1 を活用したデータ永続化
// packages/api/src/db/migrations/0001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT ( lower ( hex ( randomblob ( 16 )))),
name TEXT NOT NULL ,
email TEXT UNIQUE NOT NULL ,
bio TEXT ,
avatar_url TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' )),
updated_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ))
);
CREATE INDEX idx_users_email ON users (email);
# D1 データベースの作成とマイグレーション
npx wrangler d1 create my-rork-db
npx wrangler d1 migrations apply my-rork-db --local # ローカル開発
npx wrangler d1 migrations apply my-rork-db # 本番環境
KV キャッシュによるパフォーマンス最適化
// packages/api/src/utils/cache.ts
export async function withCache < T >(
kv : KVNamespace ,
key : string ,
ttlSeconds : number ,
fetcher : () => Promise < T >
) : Promise < T > {
const cached = await kv. get (key, 'json' );
if (cached !== null ) {
return cached as T ;
}
const fresh = await fetcher ();
await kv. put (key, JSON . stringify (fresh), {
expirationTtl: ttlSeconds,
});
return fresh;
}
// 使用例:ルーター内で重いクエリをキャッシュ
export const contentRouter = router ({
getFeaturedContent: publicProcedure
. query ( async ({ ctx }) => {
return withCache (
ctx.env. CACHE ,
'featured-content' ,
300 , // 5分間キャッシュ
async () => {
return ctx.env. DB . prepare (
'SELECT * FROM content WHERE featured = 1 ORDER BY created_at DESC LIMIT 10'
). all ();
}
);
}),
});
レート制限の実装
// packages/api/src/middleware/rateLimit.ts
import { TRPCError } from '@trpc/server' ;
import { t } from '../trpc' ;
export const rateLimitMiddleware = t. middleware ( async ({ ctx , next , path }) => {
const ip = 'client-ip' ; // ヘッダーから取得する実装が必要
const key = `rate-limit:${ ip }:${ path }` ;
const limit = 60 ; // 1分あたり60リクエスト
const current = await ctx.env. CACHE . get (key);
const count = current ? parseInt (current) : 0 ;
if (count >= limit) {
throw new TRPCError ({
code: 'TOO_MANY_REQUESTS' ,
message: 'リクエスト制限に達しました。しばらく待ってから再試行してください。' ,
});
}
await ctx.env. CACHE . put (key, String (count + 1 ), { expirationTtl: 60 });
return next ();
});
よくあるエラーと対処法
CORS エラーが発生する場合
Rork Max(Expo)の開発環境では exp://localhost:8081 からリクエストが送信されます。wrangler の設定で許可するオリジンに開発用 URL を追加してください。本番環境では Expo ビルドのディープリンクスキームも必要に応じて追加します。
Workers の nodejs_compat フラグが必要な場合
tRPC や jose ライブラリは Node.js の組み込みモジュール(crypto 等)を使用します。wrangler.toml の compatibility_flags に ["nodejs_compat"] を追加することで解決します。
型推論が効かない場合
AppRouter の型エクスポートパスが正しいかを確認してください。モノレポでは tsconfig.json のパスエイリアス設定が重要です。paths に "@api/*": ["../../packages/api/src/*"] のようなエイリアスを定義すると管理しやすくなります。
CI/CD:GitHub Actions でのデプロイ自動化
# .github/workflows/deploy-api.yml
name : Deploy API to Cloudflare Workers
on :
push :
branches : [ main ]
paths :
- 'packages/api/**'
- '.github/workflows/deploy-api.yml'
jobs :
deploy :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : pnpm/action-setup@v2
with :
version : 9
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'pnpm'
- name : Install dependencies
run : pnpm install --frozen-lockfile
- name : Run D1 migrations
working-directory : packages/api
run : npx wrangler d1 migrations apply my-rork-db
env :
CLOUDFLARE_API_TOKEN : ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name : Deploy to Cloudflare Workers
working-directory : packages/api
run : npx wrangler deploy
env :
CLOUDFLARE_API_TOKEN : ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID : ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GitHub の Secrets に CLOUDFLARE_API_TOKEN(Cloudflare ダッシュボードで発行)と CLOUDFLARE_ACCOUNT_ID を設定するだけで、main ブランチへのプッシュ時に自動デプロイが走ります。
個人開発者の視点から(実体験メモ)
ここまでの要点
Rork Max × tRPC × Cloudflare Workers の組み合わせは、個人開発者がプロフェッショナル品質のフルスタックモバイルアプリを構築するうえで、現時点で最も生産性の高い選択肢のひとつです。
型安全によりランタイムエラーが大幅に減り、Cloudflare のエッジ実行により世界中どこからでも低レイテンシを実現でき、無料枠も十分に広い。このスタックを習得することで、バックエンドとモバイルアプリの開発速度が格段に向上するはずです。
まずは小規模な API から試してみて、徐々に認証・キャッシュ・CI/CD を追加していく進め方をおすすめします。フルスタック型安全の恩恵を一度体験すると、以前の開発スタイルには戻れなくなるでしょう。
型安全なAPIアーキテクチャについてさらに深く