DEV Community

kenghow
kenghow

Posted on • Edited on

I shipped a $49 React Native template with the backend included — here's the architecture

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. App attaches the user's Supabase JWT as Authorization: Bearer <token>
  2. Worker verifies the JWT against Supabase's JWKS (cached 1h in Cloudflare KV)
  3. Worker checks the user's daily quota counter in KV
  4. 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;
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)