I just shipped a React Native template called Reptron — Expo + Supabase + a Cloudflare Worker that proxies an LLM API. The interesting bit isn't the UI; it's the backend stack that ships with it. This post is a walkthrough of how the pieces fit.
The problem
A mobile app needs to call an LLM (Groq, OpenAI, Anthropic — doesn't matter). The API key cannot ship in the bundle — anyone can decompile an APK and grab it. Per-IP rate limiting is brittle once you're behind carrier NAT, and a leaked URL becomes a free-for-all.
The fix is unsexy but works: put a thin auth-aware proxy in between.
Architecture
flowchart LR
subgraph App["React Native App"]
UI[UI screens]
Z[(Zustand + MMKV)]
UI --> Z
end
subgraph Worker["Cloudflare Worker"]
Auth[Verify JWT<br/>cached JWKS]
Quota[Daily Quota<br/>50 req/user/day]
Forward[Forward to Groq]
Auth --> Quota --> Forward
end
subgraph Backend["Supabase"]
AuthDB[(Auth users)]
Tables[(Workouts<br/>RLS enforced)]
end
Groq[("Groq Llama 3.1")]
App -->|"sign in / sync (JWT)"| Backend
App -->|"chat (JWT)"| Worker
Worker -->|"server secret"| Groq
The Groq API key lives as a Cloudflare secret on the Worker — never in the app bundle. JWT auth plus per-user daily quota mean a leaked Worker URL can't be abused by anonymous traffic.
Pipeline
Every /chat request goes through four steps:
-
App attaches the user's Supabase JWT as
Authorization: Bearer <token> - Worker verifies the JWT against Supabase's JWKS (cached 1h in Cloudflare KV)
- Worker checks the user's daily quota counter in KV
- Worker forwards to Groq's OpenAI-compatible endpoint and streams the SSE response back
JWKS cache
Verifying a JWT means fetching Supabase's public keys. Hitting that endpoint on every request would be wasteful — cache for an hour:
const cached = await env.RATE_LIMIT.get('jwks', 'json') as Cached | null;
if (cached && cached.expiresAt > Date.now()) return cached.keys;
const fresh = await fetch(`${env.SUPABASE_URL}/.well-known/jwks.json`).then(r => r.json());
await env.RATE_LIMIT.put('jwks', JSON.stringify({
keys: fresh.keys,
expiresAt: Date.now() + 3600_000,
}));
return fresh.keys;
Verify with jose. The KV get/put calls are basically free at this scale.
Per-user daily quota
KV makes a per-user counter trivial. Key includes the UTC day so the counter rolls over naturally; the TTL is 26h so the next-day key is provisioned before the rollover window:
const day = new Date().toISOString().slice(0, 10); // "2026-05-09"
const key = `quota:${userId}:${day}`;
const current = parseInt((await env.RATE_LIMIT.get(key)) ?? '0', 10);
if (current >= LIMIT) {
return new Response(JSON.stringify({
error: 'quota_exceeded',
resetAt: nextUtcMidnight(),
}), { status: 429 });
}
await env.RATE_LIMIT.put(key, String(current + 1), { expirationTtl: 26 * 60 * 60 });
Per-user is better than per-IP because it survives NAT and shared Wi-Fi. The 429 response carries resetAt so the app can show "tomorrow at midnight UTC" instead of an opaque error.
Idempotent cloud sync (Supabase + RLS)
The app stores workouts locally in MMKV. On Finish, it pushes to Supabase; on sign-in, it pulls. Three tables — workouts, workout_exercises, workout_sets — each row has a client_id UUID generated on device.
Conflict rule: last-write-wins per client_id. This means retries are safe and the sync layer doesn't need a queue or version vector.
RLS enforces ownership at the database level:
create policy "users select own workouts"
on workouts for select
using (auth.uid() = user_id);
The Supabase client is invoked with the user's JWT — Postgres reads auth.uid() from that and filters. App code can't accidentally leak another user's data because the database refuses to return it.
What's actually in the box
- React Native (Expo SDK 54, Router 6, NativeWind 4)
- Zustand + react-native-mmkv for local state
- Supabase auth, schema, and RLS
- Cloudflare Worker for the LLM proxy (JWT + quota)
- AI Coach feature backed by Groq Llama 3.1, streaming SSE end-to-end
- EN + TH i18n
- 88 passing Vitest tests (utils, stores, sync, worker)
If the pattern is useful and you don't want to wire it up from scratch, the full template is at https://8628671192007.gumroad.com/l/mbmmh — single-project commercial license.
Either way, happy to answer questions about any piece in the comments.
Top comments (0)