●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Rork Max × tRPC × Cloudflare Workers: Three Boundaries Where a Green Type Check Still Returns a 500
A full tRPC and Cloudflare Workers edge API walkthrough, plus three boundaries that slip past the type checker: D1 column names versus your output schema, KV cache return shapes, and a rate limiter that measured 30.5 requests per minute against a stated limit of 60.
Setup and context: The Hidden Cost of Untyped APIs in Mobile Development
When you're building a Rork Max app and connecting it to a backend API, a subtle but serious risk lurks beneath the surface: type divergence. The backend evolves, a field gets renamed, a response shape changes — and your mobile app continues calling the old API until a user reports a crash. By then, the damage is done.
This problem isn't unique to beginners. Even experienced TypeScript developers working on REST APIs routinely deal with the friction of manually keeping frontend and backend types in sync. You write an interface on the client, a matching type or schema on the server, and then spend mental energy ensuring they stay aligned. Code generation tools like OpenAPI Codegen or GraphQL Code Generator help, but they introduce their own complexity: schema files, generation scripts, versioning challenges.
tRPC (TypeScript Remote Procedure Call) takes a fundamentally different approach. Instead of generating types from a schema, it shares the type definitions themselves between your server and client. When you define a procedure on the server, your React Native components automatically know its input and output types — no generation step, no schema file, no manual sync. If the server type changes, your client shows a compile-time error immediately.
Pair this with Cloudflare Workers, and you have a globally distributed, low-latency backend that costs virtually nothing for indie app scales and scales seamlessly as your user base grows. This guide covers everything: project architecture, router design, authentication, caching, advanced middleware patterns, and a full CI/CD pipeline with GitHub Actions. It's written for developers comfortable with TypeScript who have shipped apps with Rork Max.
What tRPC Guarantees, and What It Does Not
Before the implementation, here is the map I wish I had drawn first. Everything below the third row is a boundary you have to close by hand, and the first time I skipped one it cost me a production incident: a getProfile procedure that compiled cleanly, autocompleted perfectly in the editor, and returned INTERNAL_SERVER_ERROR on every single call.
Boundary
Type guarantee
What happens when it breaks
Client ↔ router definition
Yes (shared AppRouter type)
The build fails. It never reaches production
Input ↔ Zod schema
Yes (validated at runtime)
BAD_REQUEST comes back. An expected failure
D1 column names ↔ output schema
No
.output() throws at runtime — every request 500s
KV return value ↔ declared type
No (as T waves it through)
A differently shaped value arrives wearing the right type
Environment secrets existing
No
You find out on the first request after deploy
Only the top two rows are covered by the compiler. The bottom three stay open unless you deliberately close them, and this guide flags each one as we reach it.
Understanding tRPC's Core Concepts
Procedures: The Building Blocks
In tRPC, everything revolves around procedures — server-side functions that your client calls directly. There are three types. A query is for reading data (equivalent to an HTTP GET). A mutation is for writing data (equivalent to a POST, PUT, or DELETE). A subscription is for real-time data streams over WebSocket, though we won't cover subscriptions in this guide since Cloudflare Workers has limited WebSocket support.
What makes procedures special is that they're defined with full TypeScript types, and those types are exported as a single AppRouter type that your client imports. No REST specification. No GraphQL schema. Just TypeScript.
Zod: Your First Line of Defense
tRPC uses Zod for input validation. Every procedure that accepts arguments must define a Zod schema for its input. This gives you two things at once: compile-time type safety (TypeScript infers types from Zod schemas) and runtime validation (malformed requests are rejected before they reach your business logic). For a mobile app where you control both client and server, you might wonder if runtime validation is really necessary. It is — because your API will eventually be called by users with older app versions, or potentially by third-party clients, and Zod protects you in all those cases.
The Router as a Contract
An appRouter in tRPC is essentially a typed API contract. When you export type AppRouter = typeof appRouter, you're exporting a structural description of every endpoint your backend exposes — its name, its input schema, and its output type. Your mobile app imports only this type (not any runtime code), which means you can keep your backend package out of your mobile bundle entirely.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦See exactly why putting SELECT * and .output() in the same procedure returns a 500 on every request, and get the mapper function that closes the gap
✦Read the measured numbers behind a rate limiter that admits 30.5 requests per minute when it claims 60, plus a fixed-window rewrite you can drop in
✦Learn why a KV cache wrapper hands back a D1Result wearing an array's face, and how waitUntil removes the write latency from every cache miss
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Cloudflare Workers doesn't run your code in a single data center. It deploys your code to Cloudflare's network of 300+ data centers worldwide and executes each request at the location closest to the user making it. This is fundamentally different from traditional serverless functions like AWS Lambda, where your function runs in one region and adds latency for users elsewhere.
For a Rork Max app targeting a global audience, this difference is significant. A user in Tokyo calling your API doesn't need to wait for a round trip to a data center in US-East. Their request hits a Cloudflare data center in or near Japan and gets a response in milliseconds. This is what "edge computing" means in practice.
No Cold Starts
AWS Lambda and Google Cloud Functions suffer from cold starts: the first request after a period of inactivity can take several hundred milliseconds to a few seconds while the runtime initializes. Cloudflare Workers doesn't have this problem because it uses V8 isolates rather than full VMs or containers. Your code is always warm. For a mobile app where users expect instant feedback, eliminating cold starts noticeably improves the experience.
The Economics for Indie Developers
Cloudflare Workers' free tier is genuinely generous: 100,000 requests per day, 10ms CPU time per request, and access to KV storage with 100,000 reads and 1,000 writes per day. For most apps at the indie stage, this is enough to run indefinitely without paying anything. The paid Workers Paid plan is $5/month and adds 10 million requests per month and 30ms CPU time — enough to handle a modestly successful app with tens of thousands of daily active users.
Compare this to running even the smallest EC2 instance or a managed Postgres database, and the cost difference is stark. This stack lets you launch a production-quality backend for free and only start paying when you have meaningful revenue to justify it.
Environment Setup and Project Architecture
Choosing a Monorepo Structure
The key to making tRPC work is sharing the AppRouter type between packages. The cleanest way to achieve this in a Rork Max project is a monorepo managed with pnpm workspaces. You have your mobile app in apps/mobile and your API in packages/api. The mobile app imports only the TypeScript type from the API package — no runtime code crosses the boundary.
The jose library handles JWT signing and verification in Workers (native browser crypto API). We use Hono as a lightweight wrapper around the Workers fetch handler because it makes CORS configuration, logging, and route grouping much cleaner than raw Workers code.
The nodejs_compat flag is essential — tRPC and jose rely on Node.js built-ins like crypto that aren't available in the default Workers runtime. This flag adds compatibility polyfills for those modules.
Building the tRPC Router Layer
Initializing tRPC and Defining Context
Context is what makes tRPC procedures aware of their environment. Your context contains the Cloudflare environment bindings (D1 database, KV namespace, secrets) and any per-request data like the authenticated user ID. You define it once and every procedure has access to it through the ctx parameter.
// packages/api/src/trpc.tsimport { initTRPC, TRPCError } from '@trpc/server';import { z } from 'zod';export 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;// Open to everyoneexport const publicProcedure = t.procedure;// Requires a valid JWT — throws UNAUTHORIZED otherwiseexport const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Authentication required', }); } return next({ ctx: { ...ctx, userId: ctx.userId } });});
The errorFormatter ensures that Zod validation errors are surfaced to the client in a structured, queryable format. When input validation fails, the client receives error.data.zodError.fieldErrors — an object mapping each invalid field to its error messages. This lets your Rork Max form components show precise, field-level errors without any additional error parsing logic.
Root Router Composition
Organizing your router by domain keeps each file focused and testable. The root router simply composes them:
// packages/api/src/router/index.tsimport { 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;
A Well-Designed User Router
Here's a user router that demonstrates the full range of tRPC patterns: schema definition with Zod, output type contracts, database queries via D1, and proper error handling:
// packages/api/src/router/user.tsimport { 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: 'User not found', }); } // This line does not work in production. Explanation follows 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: 'No fields to update' }); } 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 }; }),});
Defining both .input() and .output() on a procedure is a useful discipline for premium APIs. The output schema acts as a contract: if your database query returns a shape that doesn't match UserSchema, you'll get a runtime error in development before it reaches production.
Why SELECT * Plus .output() Returns a 500 on Every Call
The getProfile procedure above type-checks. It also fails in production on every request, for two reasons that both live outside the type system.
First, the column names. The migration later in this guide defines the columns as avatar_url and created_at. D1's .first() hands them back exactly as written. UserSchema, meanwhile, requires avatarUrl and createdAt. Because .output() validates the response at runtime, the required createdAt property is missing and Zod throws. The as z.infer<typeof UserSchema> cast only lies to TypeScript — at runtime it does nothing at all.
Second, the id format.UserSchema asks for z.string().uuid(). SQLite's lower(hex(randomblob(16))) produces a 32-character hex string with no separators. I generated one to check the shape:
1d6bd9d5eea00bf5749725b94279a2fe ← 32 characters, no hyphens
An RFC 4122 UUID is 36 characters in an 8-4-4-4-12 layout, with a version digit at position 15 and a variant digit at position 20. This value satisfies none of that. So even after fixing the column names, the id check would throw next.
The fix is to run every database row through one conversion function before it leaves the handler. Keeping the boundary in a single place means that when you add a column later, there is exactly one file to update.
// packages/api/src/db/mappers.tsimport { z } from 'zod';// The raw DB row: snake_case, id is unhyphenated hexconst UserRowSchema = z.object({ id: z.string().regex(/^[0-9a-f]{32}$/), name: z.string(), email: z.string().email(), bio: z.string().nullable(), avatar_url: z.string().nullable(), created_at: z.string(), updated_at: z.string(),});// The shape the API actually returns: camelCaseexport const UserSchema = z.object({ id: z.string().regex(/^[0-9a-f]{32}$/), 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 function toUser(row: unknown): z.infer<typeof UserSchema> { // A failure here tells you immediately that the DB schema moved const r = UserRowSchema.parse(row); return { id: r.id, name: r.name, email: r.email, bio: r.bio ?? undefined, avatarUrl: r.avatar_url ?? undefined, createdAt: r.created_at, };}
In the router, return result as ... becomes return toUser(result). Removing the cast means a renamed column now fails inside UserRowSchema.parse, with an error that names the missing field. Leave the cast in place and the failure surfaces one layer later, in a message that gives no hint that column naming is the culprit.
You could also alias in SQL (SELECT avatar_url AS avatarUrl), and for a single query that is faster to write. I stopped doing it once I had four procedures reading the same table, because the aliases drifted apart and the mismatch moved from one obvious place to four subtle ones.
Hono is a lightweight web framework for Cloudflare Workers. Its @hono/trpc-server package provides a middleware that bridges tRPC and Hono's request handling. The combination is cleaner than writing raw Workers fetch handlers because Hono gives you composable middleware for CORS, logging, and route grouping.
// packages/api/src/index.tsimport { 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';export interface Env { DB: D1Database; CACHE: KVNamespace; JWT_SECRET: string; ENVIRONMENT: string;}const app = new Hono<{ Bindings: Env }>();app.use('/trpc/*', cors({ origin: ['https://your-rork-app.com', 'exp://localhost:8081'], allowMethods: ['GET', 'POST', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], credentials: true,}));app.use('/trpc/*', logger());app.use('/trpc/*', trpcServer({ router: appRouter, createContext: async (opts, c) => createContext(opts, c),}));app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));export default app;
JWT Authentication in Context
The context creation function runs on every request and is where you extract and verify the JWT token. The important design choice here is that an invalid or missing token doesn't throw — it simply leaves userId as undefined. Protected procedures will then reject unauthorized requests. This allows a single entry point to serve both public and protected procedures without route-level auth guards.
// packages/api/src/context.tsimport { 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 { // Invalid or expired token — proceed as unauthenticated } } return { env: c.env, userId };}
Building the Rork Max Client
Initializing the tRPC Client
The crucial step here is importing AppRouter as a type-only import. This ensures no backend code ends up in your mobile bundle — you're importing only the TypeScript shape, which the compiler strips entirely at build time.
The httpBatchLink automatically batches multiple tRPC calls that happen in the same render cycle into a single HTTP request. This is a significant performance win for screens that need to fetch several pieces of data simultaneously — instead of three separate round trips, you make one.
Setting Up React Query Provider
tRPC uses React Query under the hood for caching, refetching, and loading state management. You need to provide both a QueryClient and the trpc provider:
Notice that you never had to define types for profile. TypeScript infers them directly from your server-side UserSchema via AppRouter. If you add a field to the user profile on the server, it immediately appears as a typed property here. If you remove a field, any client code accessing that field becomes a compile error.
Advanced Production Patterns
Database Schema and Migrations with D1
Cloudflare D1 is a SQLite-compatible database that runs at the edge alongside your Workers code. Define your schema in SQL migration files and manage them with Wrangler:
-- packages/api/src/db/migrations/0001_create_users.sqlCREATE 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);
Note the id default. lower(hex(randomblob(16))) produces an unhyphenated 32-character hex string, which — as covered earlier — will not satisfy z.string().uuid(). If you want real UUIDs, drop the DEFAULT and generate crypto.randomUUID() in application code before inserting. The Workers runtime supports it natively.
# Create and apply migrationsnpx wrangler d1 create my-rork-dbnpx wrangler d1 migrations apply my-rork-db --local # Local devnpx wrangler d1 migrations apply my-rork-db # Production
D1 queries are simple: prepare a parameterized statement with .prepare(), bind values with .bind(), and execute with .first() (one row), .all() (all rows), or .run() (mutations).
KV Caching — Don't Store .all() Straight From D1
Caching is easy to write and, for the same reason, easy to get subtly wrong. Here is the naive version:
D1's .all() returns { results, success, meta }, not an array. Callers expecting an array call .map() and crash
Unwrap .results inside the fetcher
cached !== null as the hit test
A stored value of null is indistinguishable from a missing key
Wrap the value in an envelope object
await kv.put(...)
The response waits for the write. That latency is added to every cache miss
Hand the write to executionCtx.waitUntil()
With all three addressed:
// packages/api/src/utils/cache.tstype Envelope<T> = { v: T };export async function withCache<T>( kv: KVNamespace, key: string, ttlSeconds: number, fetcher: () => Promise<T>, waitUntil?: (p: Promise<unknown>) => void): Promise<T> { // The envelope separates "the value is null" from "the key is absent" const cached = await kv.get<Envelope<T>>(key, 'json'); if (cached) return cached.v; const fresh = await fetcher(); const write = kv.put(key, JSON.stringify({ v: fresh }), { expirationTtl: ttlSeconds, }); // Return without waiting for the write to land if (waitUntil) waitUntil(write); else await write; return fresh;}
In the router, pull results out of the D1 response before it reaches the cache. Skip this and both the hit path and the miss path return the same shape — which sounds fine until you notice that neither of them is the array your component is iterating.
export const contentRouter = router({ getFeaturedContent: publicProcedure.query(async ({ ctx }) => { return withCache( ctx.env.CACHE, 'featured-content', 300, async () => { const { results } = await ctx.env.DB.prepare( 'SELECT * FROM content WHERE featured = 1 ORDER BY created_at DESC LIMIT 10' ).all<ContentRow>(); return results.map(toContent); // handle column naming here too }, ctx.waitUntil ); }),});
Bind ctx.waitUntil when you build the context, wrapping Hono's c.executionCtx.waitUntil. Pass the method unbound and you get Illegal invocation at runtime, so use the (p) => c.executionCtx.waitUntil(p) form.
One more property worth internalizing: KV writes take time to propagate across Cloudflare's points of presence. That is acceptable for a cache. It is not acceptable for anything where miscounting matters — which brings us to rate limiting.
Rate Limiting — The Code That Says 60/min Measured 30.5/min
This is the piece I ran in production the longest while it was quietly wrong. Start with the version you'll find in most write-ups, including an earlier draft of this one:
// Does not enforce the number it advertisesexport const rateLimitMiddleware = t.middleware(async ({ ctx, next, path }) => { const ip = 'client-ip'; // placeholder — read from CF-Connecting-IP const key = `rate-limit:${ip}:${path}`; const limit = 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();});
Three separate problems.
The IP is a literal string.'client-ip' is a placeholder, and left unchanged it puts every caller in one shared bucket — ten concurrent users each get a tenth of the allowance. On Cloudflare Workers, request.headers.get('CF-Connecting-IP') gives you the real client address. Don't reach for X-Forwarded-For instead; a client can set that header to anything it likes.
The TTL resets on every write. This is the one that took me longest to see. Each increment passes expirationTtl: 60 again, so the expiry keeps sliding forward to "sixty seconds after the most recent write." The window never closes; it just keeps stretching.
To find out how far off it lands, I reimplemented the KV semantics in plain JavaScript — entries vanish when the TTL elapses, and put refreshes the expiry — and ran ten minutes of steady traffic through both versions.
Traffic
Admitted by the code above
Admitted by a time-bucketed window
1 req/sec (exactly the stated limit)
305 requests = 30.5/min
600 requests = 60.0/min
2 req/sec (twice the stated limit)
420 requests = 42.0/min
600 requests = 60.0/min
At exactly the advertised rate — traffic that should never be rejected — roughly half of it was. The stranger result is the second row: doubling the incoming rate increases the number of requests that get through, from 30.5 to 42.0 per minute. Once a client is over the limit the middleware throws before it writes, so the expiry stops sliding and the window reopens sooner. Tightening the pressure loosens the limit, which is exactly the direction that makes the bug invisible in your logs.
KV is eventually consistent. Writes take time to reach every point of presence, so simultaneous requests hitting different locations each read a stale counter and increment it. This will never be an exact ceiling.
The fix puts a time bucket in the key, so writing to it can't move the window boundary:
// packages/api/src/middleware/rateLimit.tsimport { TRPCError } from '@trpc/server';import { t } from '../trpc';const WINDOW_SEC = 60;const LIMIT = 60;export const rateLimitMiddleware = t.middleware(async ({ ctx, next, path }) => { // Cloudflare sets CF-Connecting-IP itself, so a client can't forge it const ip = ctx.req.headers.get('CF-Connecting-IP') ?? 'unknown'; // The time bucket in the key keeps put() from moving the boundary const bucket = Math.floor(Date.now() / 1000 / WINDOW_SEC); const key = `rl:${ip}:${path}:${bucket}`; const count = Number(await ctx.env.CACHE.get(key)) || 0; if (count >= LIMIT) { const retryAfter = WINDOW_SEC - Math.floor((Date.now() / 1000) % WINDOW_SEC); throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: `Too many requests. Try again in ${retryAfter} seconds.`, }); } // Two windows of TTL so a write near the boundary isn't lost await ctx.env.CACHE.put(key, String(count + 1), { expirationTtl: WINDOW_SEC * 2, }); return next();});export const rateLimitedProcedure = publicProcedure.use(rateLimitMiddleware);
Carry opts.req through when you build the context so ctx.req is available here. Only the headers are needed, so passing just headers works equally well.
The eventual-consistency caveat survives this fix. When the ceiling has to be exact — proxying a metered third-party API, where an overage shows up on an invoice — you need the counter in one place. A Durable Object processes requests for a given object serially, which removes the read-modify-write race entirely; the state-management patterns in Building a Real-Time Collaborative App Backend with Rork and Cloudflare Durable Objects transfer over almost unchanged.
For the specific case of shielding an external API key behind a Worker, Hardcoding Your OpenAI Key in a Rork (Expo) App Means It Gets Stolen covers the proxy side. Whether you're protecting your own compute or someone else's metered endpoint changes how much counting error you can live with.
tRPC middleware composes cleanly, so rate limiting, authentication, and logging can be chained onto a single procedure type with successive .use() calls.
Troubleshooting Common Issues
CORS Errors in Expo Development
Worth stating plainly first: CORS does not apply to React Native running natively. It is a browser mechanism — the browser attaches an Origin header and issues preflight requests — and the native fetch implementation isn't subject to it. Listing exp://localhost:8081 in your origin array has no effect, because nothing is ever matched against it.
CORS configuration matters when you export through Expo Router for web, or when you test endpoints from a browser. For those, list HTTP origins such as http://localhost:8081. Which also means: if you're targeting native only and you're still seeing CORS errors, the request is coming from a browser somewhere, or your routing is rejecting the preflight before Hono's middleware runs.
Workers Fails to Start with nodejs_compat
If you see errors about missing Node.js modules like crypto or buffer, ensure compatibility_flags = ["nodejs_compat"] is present in your wrangler.toml. Without it, any npm package that internally uses Node.js built-ins will fail at startup.
Type Inference Broken After Adding a New Procedure
The most common cause is a circular import. If your new router file imports from a file that (directly or indirectly) imports from the router itself, TypeScript's type inference can break. Keep your router files import-clean: routers import from ../trpc and utility modules, never from other routers or the root index.ts.
D1 Query Returns Unexpected Column Names
Cloudflare D1 returns column names exactly as they appear in the database schema. If your schema uses avatar_url (snake_case) but your TypeScript type expects avatarUrl (camelCase), the Zod parse will fail. Either normalize column names in SQL using AS aliases (SELECT avatar_url AS avatarUrl FROM users) or transform the result in the query handler before returning.
CI/CD Pipeline with GitHub Actions
Automating deployment removes the risk of forgetting a step, ensures migrations always run before new code deploys, and gives you a deployment audit trail through GitHub Actions history.
Add CLOUDFLARE_API_TOKEN (generated from the Cloudflare dashboard under API Tokens with Workers and D1 permissions) and CLOUDFLARE_ACCOUNT_ID to your repository's Secrets. From that point on, every merge to main that touches the API package automatically runs type checks, applies migrations, and deploys to the global edge network.
For even more confidence, add a staging environment by creating a second wrangler.toml environment block and a separate GitHub Actions job that deploys to staging on pull requests, with production deployment gated on merge to main.
A Note from an Indie Developer
What I appreciate most about this stack is that failures moved earlier. The old sequence was "the app crashed, go read the server logs." Now it is more often "the types don't line up, the build stopped." The time I used to spend locating a problem largely disappeared.
And yet all three defects in this article compiled without complaint. The column mismatch, the cache return shape, the rate-limit arithmetic — as far as TypeScript was concerned, every one of them was correct code. A green type check is not evidence that the design is right. Obvious in the abstract; I only felt it after watching production return 500s from a procedure the editor had been autocompleting happily.
So if you're adopting this, go in order. Build one router with two or three procedures, call them from a real device, and look at the values that come back. Then add auth, then caching, then rate limiting. Assemble it all at once and you lose the ability to tell which layer bent the shape.
What tRPC plus Cloudflare Workers genuinely solves is drift between your client and your router definitions. Rename a field, change a return shape, and the build stops before anyone downloads it. That part is as good as it sounds.
The three things this guide fixed all sat outside that guarantee.
What was fixed
Symptom
The change
D1 columns vs. output schema
Type check green, every request 500s in production
One toUser() mapper at the boundary
withCache return value
.all() hands back a D1Result wearing an array's face
Unwrap results; move the write to waitUntil
Rate limit counter
A stated 60/min measured at 30.5/min
Time bucket in the key to pin the window
A good next step: open the backend you're running now, pick one procedure with an .output() schema, and compare it against the actual column names in your database. If SELECT * and .output() appear in the same handler, the odds are high that it has the same problem. The check takes a few minutes, and finding one removes a class of production 500s.
I'm still rearranging parts of this setup myself. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.