ReadyNative

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:

.env
EXPO_PUBLIC_API_URL=https://api.example.com

setup 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

Pro

This 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.ts handlers with the shipped src/server/json.ts helpers (handle, json, readJson with zod) and serverEnv() 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

Pro

Postgres 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:

  1. Pick it at setup:
    bun run setup --auth supabase
    Already finalized without it? Re-clone and re-run setup (FAQ).
  2. Create a project at supabase.com and paste the Project URL and anon key into .env as EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY.
  3. Create your first table in the SQL editor and turn on row-level security.
  4. Query it with the supabase client 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, mutation and action functions in a convex/ folder, called through useQuery and useMutation in components.
  • Where it runs: Convex's cloud; npx convex dev syncs 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:

  1. bunx expo install convex
  2. bunx convex dev
    creates the convex/ folder and writes EXPO_PUBLIC_CONVEX_URL to .env.local.
  3. Wrap the app in ConvexProvider in src/providers.tsx with new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!).
  4. Add a query in convex/tasks.ts and read it with useQuery(api.tasks.get).
  5. bunx convex deploy
    pushes to production when you're ready.

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 Dockerfile or 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):

  1. bun create hono@latest my-api
    and pick the cloudflare-workers template.
  2. Add a route: app.get("/health", (c) => c.json({ ok: true })).
  3. bunx wrangler dev
    serves it at http://localhost:8787.
  4. bunx wrangler deploy
    gives you https://<worker>.<subdomain>.workers.dev.
  5. Set EXPO_PUBLIC_API_URL to that origin and store secrets with wrangler secret put <NAME>.

Side by side, the four rungs compare like this:

OptionYou writeRuns onDatabase included?Auth included?Free tier
Expo API routes+api.ts handlersEAS Hostingnowith auth/better-auth100k requests/month
SupabaseSQL + client callsSupabase cloudPostgresyes (auth/supabase)2 projects, 500 MB DB
ConvexTypeScript functionsConvex cloudreactive document DBvia Convex Auth or Clerk1M calls/month
Your own serverHono / Express appRailway, Fly.io, Workers, Vercelbring your ownbring your ownvaries 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 .env under the # server (never EXPO_PUBLIC) heading; in production they go in the host's secret store (eas env:set --visibility sensitive for EAS Hosting, wrangler secret put, the Railway or Vercel dashboard). Read them through serverEnv() so a missing key fails at startup, not in a request.
  • A /health route. The module ships GET /api/health; keep one on any server so you, curl and 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:8081 explicitly 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.

OptionClient keys (EXPO_PUBLIC_)Server keysdoctor checks
Expo API routesEXPO_PUBLIC_API_URLWEBHOOK_SECRETEXPO_PUBLIC_API_URL
+ Better AuthEXPO_PUBLIC_API_URLBETTER_AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URLthe first three
+ StripeEXPO_PUBLIC_API_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEYSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_IDall five
SupabaseEXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEYnone in the appboth
ConvexEXPO_PUBLIC_CONVEX_URLset in the Convex dashboardnot a module; nothing
Your own serverEXPO_PUBLIC_API_URLwhatever the server readsnothing

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.

On this page

Get ReadyNative