Do I need a backend?
What a backend is for a mobile app, how to call one you already have, and which one to start with if you don't.
Hey - this is the page I'd read before picking anything in the backend category. By the
end you'll know whether your app needs a server at all, how to point ReadyNative at one you
already run, and which one to start with if you have none.
A "backend" is everything that must not live on the phone. Secrets (a Stripe key, an API
token) are readable by anyone who unzips your app, so they need a server. Data that two users
share - a feed, a leaderboard, a team - needs a place both phones can reach. Payments have to
be verified somewhere the buyer can't tamper with, and push notifications are sent by a
server, not by the app. If your app is a calculator, a timer or a notes app that syncs
nowhere, you don't need one and bun run setup --backend none is the right answer.
The decision is short. If you already have a backend, use it: set EXPO_PUBLIC_API_URL
and call it through src/lib/api/client.ts - the calls are in the
API routes usage section. If you don't, start
with the built-in one: Expo API routes in the same tree, deployed to EAS Hosting.
You already have a backend
Then ReadyNative is a client, and all it needs is the origin. Put it in .env:
EXPO_PUBLIC_API_URL=https://api.example.comsetup lists this key in .env.example whenever a data, auth or backend module wants it,
and src/lib/env.ts exposes it as env.API_URL after zod validation. Relative paths passed
to fetchJson resolve against it; absolute URLs pass through:
import { fetchJson } from "@/lib/api/client";
const me = await fetchJson<{ id: string; email: string }>("/v1/me");Non-2xx responses reject with an ApiError carrying status and the parsed body, and every
call times out after 15 seconds unless you pass timeoutMs. To authenticate, add the header
yourself. auth.useSession() from @/lib/auth tells you whether you're signed in under
every auth option; the token itself comes from the vendor client, because each vendor issues a
different kind. With Supabase:
import { supabase } from "@/lib/auth";
import { fetchJson } from "@/lib/api/client";
const { data } = await supabase!.auth.getSession();
const token = data.session?.access_token;
await fetchJson("/v1/me", { headers: token ? { authorization: `Bearer ${token}` } : {} });Clerk gives you a JWT through useAuth().getToken(), and Better Auth sends a cookie, so its
routes have to live on the same origin as EXPO_PUBLIC_API_URL. Each option's page under
Auth shows the call.
Two more things. Native apps don't send an Origin header, so CORS never blocks them - but
the web build does, so if you ship to the web, allow your app's web origin (and
http://localhost:8081 in dev) on the server. And never put a secret behind an EXPO_PUBLIC_
prefix: those keys are inlined into the JavaScript bundle and anyone can read them. Your
backend holds the secrets; the app holds only the URL.
You don't have one yet
Here's the ladder I'd climb. Each rung is a complete answer; most apps never leave the first one.
Expo API routes on EAS Hosting
ProThis is the backend/api-routes module, the default backend in the Pro saas preset; it and
the src/server/* helpers below only exist in a Pro tree set up with backend=api-routes. A
file named +api.ts under
src/app/api/ exports GET, POST and friends, runs on the dev server while expo start
is up, and runs on EAS Hosting (Cloudflare Workers under the hood) once deployed. Both
auth/better-auth and payments/stripe require it.
- Good for: a handful of endpoints, webhooks, hiding a third-party key, the first version of almost anything.
- What you write:
+api.tshandlers with the shippedsrc/server/json.tshelpers (handle,json,readJsonwith zod) andserverEnv()for secrets. - Where it runs: your dev machine in development, EAS Hosting in production, same repo.
- Free tier / cost shape: the free Expo plan includes 100,000 requests a month and 1 GB of storage; custom domains need a paid plan.
- When you outgrow it: you need a database (pair it with Supabase or a hosted Postgres), long-running jobs, websockets, or a Node module the Workers runtime can't load.
First deploy: pick it at setup (bun run setup --backend api-routes), then follow
Deploy your API routes - the local health check, server keys on EAS,
eas deploy, and pointing your builds at the hosted URL through an EAS environment.
Supabase
ProPostgres with a REST API, auth, file storage, realtime and edge functions, all behind one
dashboard. It pairs with auth/supabase, so if you picked that auth option you already have
a backend.
- Good for: shared data with row-level security, user accounts and files without writing a server.
- What you write: SQL (tables and policies) and
supabase.from("…")calls from the app; Deno edge functions when you need server code. - Where it runs: Supabase's cloud, in the region you pick.
- Free tier / cost shape: two active projects, a 500 MB database, 50,000 monthly active users, 1 GB of file storage and 500,000 edge function invocations; free projects pause after a week of inactivity.
- When you outgrow it: you want business logic in TypeScript rather than SQL policies, or a runtime other than Deno for server code.
First deploy:
- Pick it at setup: Already finalized without it? Re-clone and re-run setup (FAQ).
bun run setup --auth supabase - Create a project at supabase.com and paste the Project URL and anon key into
.envasEXPO_PUBLIC_SUPABASE_URLandEXPO_PUBLIC_SUPABASE_ANON_KEY. - Create your first table in the SQL editor and turn on row-level security.
- Query it with the
supabaseclient exported from@/lib/auth- there's nothing to deploy.
Convex
A reactive database and serverless functions in one, TypeScript end to end: queries you subscribe to re-run in the app whenever the data changes.
- Good for: collaborative and live-updating apps, or when you'd rather never think about caching and invalidation.
- What you write:
query,mutationandactionfunctions in aconvex/folder, called throughuseQueryanduseMutationin components. - Where it runs: Convex's cloud;
npx convex devsyncs your functions as you save. - Free tier / cost shape: 1 million function calls, 0.5 GB of database storage and 1 GB of file storage included, then pay-as-you-go.
- When you outgrow it: you need raw SQL, a specific database engine, or to run on your own infrastructure.
First deploy:
-
bunx expo install convex - creates the
bunx convex devconvex/folder and writesEXPO_PUBLIC_CONVEX_URLto.env.local. - Wrap the app in
ConvexProviderinsrc/providers.tsxwithnew ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!). - Add a
queryinconvex/tasks.tsand read it withuseQuery(api.tasks.get). - pushes to production when you're ready.
bunx convex deploy
A small server you own
Hono or Express on Railway, Fly.io, Cloudflare Workers or Vercel Functions. Pick this when the API is the product - many endpoints, background jobs, websockets, a queue, a database you administer - or when your team already runs one of these hosts.
- Good for: anything the rungs above can't run, and teams that want full control.
- What you write: a normal HTTP server, plus a
Dockerfileor a host config file. - Where it runs: Railway and Fly.io run containers (long-lived processes, websockets, cron); Cloudflare Workers and Vercel Functions run request handlers that scale to zero.
- Free tier / cost shape: Cloudflare Workers gives 100,000 requests a day free and $5/month after; Vercel's Hobby plan includes 1 million invocations a month for non-commercial use; Railway offers a $5 one-time trial credit and a $5/month Hobby plan; Fly.io's trial is 2 machine-hours or 7 days, then usage billing.
- When you outgrow it: you don't - this is the top of the ladder. What changes is how much of it you operate yourself.
First deploy (Hono on Cloudflare Workers, the cheapest of the four to keep running):
- and pick the
bun create hono@latest my-apicloudflare-workerstemplate. - Add a route:
app.get("/health", (c) => c.json({ ok: true })). - serves it at
bunx wrangler devhttp://localhost:8787. - gives you
bunx wrangler deployhttps://<worker>.<subdomain>.workers.dev. - Set
EXPO_PUBLIC_API_URLto that origin and store secrets withwrangler secret put <NAME>.
Side by side, the four rungs compare like this:
| Option | You write | Runs on | Database included? | Auth included? | Free tier |
|---|---|---|---|---|---|
| Expo API routes | +api.ts handlers | EAS Hosting | no | with auth/better-auth | 100k requests/month |
| Supabase | SQL + client calls | Supabase cloud | Postgres | yes (auth/supabase) | 2 projects, 500 MB DB |
| Convex | TypeScript functions | Convex cloud | reactive document DB | via Convex Auth or Clerk | 1M calls/month |
| Your own server | Hono / Express app | Railway, Fly.io, Workers, Vercel | bring your own | bring your own | varies by host |
Things every backend needs
Whichever rung you're on, the same short list keeps you out of trouble:
- Secrets live on the server. Locally they go in
.envunder the# server (never EXPO_PUBLIC)heading; in production they go in the host's secret store (eas env:set --visibility sensitivefor EAS Hosting,wrangler secret put, the Railway or Vercel dashboard). Read them throughserverEnv()so a missing key fails at startup, not in a request. - A
/healthroute. The module shipsGET /api/health; keep one on any server so you,curland your uptime monitor can tell "deployed" from "working". - CORS for the web build. Native ignores it; the browser doesn't. Allow your web origin
and
http://localhost:8081explicitly rather than*once auth cookies are involved. - A version in the path.
/v1/…costs nothing today and lets old app builds keep working when you change a response shape - phones don't update the day you deploy. - Logging you can read. If you picked
crash/sentry, add the Sentry server SDK to the same project so a failed request and the crash it caused show up together. See Crash reporting. - Rate limiting. Any route a signed-out user can hit (sign-in, password reset, a webhook) needs a per-IP limit - EAS Hosting, Workers and Vercel all have one you can turn on.
What to deploy first
A /health route, one real endpoint, and the secret it needs - then point a dev build at the
hosted URL and tap through the flow on a phone before you write the second endpoint.
Where the keys go
Every option reduces to a few lines in .env. bun run doctor checks the ones a module marks
as required and prints the dashboard and guide links for each missing one - the rows are
explained in Doctor.
| Option | Client keys (EXPO_PUBLIC_) | Server keys | doctor checks |
|---|---|---|---|
| Expo API routes | EXPO_PUBLIC_API_URL | WEBHOOK_SECRET | EXPO_PUBLIC_API_URL |
| + Better Auth | EXPO_PUBLIC_API_URL | BETTER_AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL | the first three |
| + Stripe | EXPO_PUBLIC_API_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID | all five |
| Supabase | EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY | none in the app | both |
| Convex | EXPO_PUBLIC_CONVEX_URL | set in the Convex dashboard | not a module; nothing |
| Your own server | EXPO_PUBLIC_API_URL | whatever the server reads | nothing |
The anon key and the publishable key are safe in the client by design; a secret key, a webhook signing secret or a database URL never is.
Next
- API routes - the module in detail, with the helpers and the example route.
- Deploy your API routes - the tutorial that puts them on EAS Hosting.
- Auth - Supabase, Clerk or Better Auth on top of whichever backend you chose.
No ios/ or android/ folders
How ReadyNative keeps native projects generated instead of committed - where native config lives, how to add a native library, how SDK upgrades stay a version bump, and how that pairs with over-the-air updates.
Add web checkout next to the App Store
Run RevenueCat and Stripe Checkout side by side - one entitlement, two ways to pay, App Review-safe on iOS.