Everyone talks about shipping the React Native app. Nobody talks about the boring half: the API it phones home to, the Postgres behind that API, the cron that expires stale sessions, and the webhook endpoint your payment provider hammers at 3am. Expo (https://docs.expo.dev) is fantastic at the client — EAS Build, EAS Update, push notifications — but it deliberately doesn't run your backend. That's on you. I run PandaStack, so I'll show you the backend half end to end, and I'll be honest about the parts Expo already does better than I could.
What Expo gives you, and what it doesn't
EAS (Expo Application Services) handles the mobile-specific work brilliantly: cloud builds for iOS/Android, over-the-air JS updates so you can push a bug fix without an App Store review, and a push notification service. Keep using all of that — there's no reason to reinvent it.
What EAS does not give you is a place to run server code, a managed database, or scheduled jobs. That's the gap we're filling. A typical React Native app needs:
- An HTTP API (auth, user data, business logic)
- A database (Postgres is the sane default)
- Somewhere to receive webhooks (Stripe, RevenueCat, Twilio)
- Scheduled cleanup / digest jobs
- Optionally, a self-hosted OTA update endpoint if you outgrow EAS Update
PandaStack runs all of that. The mobile app stays on Expo; the server lives here.
Step 1: A minimal API your app can talk to
We'll use a plain Node + Express API because it's the least surprising thing to debug from a phone on a hotel wifi. server.js:
import express from 'express'
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL, { ssl: 'require' })
const app = express()
app.use(express.json())
app.get('/health', (_req, res) => res.json({ ok: true }))
app.get('/me/:id', async (req, res) => {
const [user] = await sql`select id, name, created_at from users where id = ${req.params.id}`
if (!user) return res.status(404).json({ error: 'not found' })
res.json(user)
})
const port = process.env.PORT || 8080
app.listen(port, () => console.log(`api on :${port}`))Note process.env.DATABASE_URL. On PandaStack, when you attach a managed database to an app, that variable is injected for you — you don't paste credentials anywhere.
Step 2: Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]Step 3: Create the database and deploy
From the dashboard (https://dashboard.pandastack.io):
- 1Databases → New Database → PostgreSQL. On the free tier you get one database — plenty for launch.
- 2New App → connect your Git repo. PandaStack auto-detects the Dockerfile (or, if you delete it, the Node buildpack), builds with rootless BuildKit, and deploys.
- 3In the app's settings, attach the database.
DATABASE_URLnow appears in the app's environment automatically. - 4Push to your default branch and every commit redeploys.
Prefer the terminal? The CLI does the same:
npm install -g @pandastack/cli
panda login
panda deployYour API is now at https://your-app.pandastack.io. Point your Expo app's API base URL at it.
Step 4: Wire the mobile app to it
In your Expo project, keep the base URL in an env-driven config so dev and prod differ:
// api.js
const BASE = process.env.EXPO_PUBLIC_API_URL // e.g. https://your-app.pandastack.io
export async function getMe(id) {
const r = await fetch(`${BASE}/me/${id}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}EXPO_PUBLIC_ variables are inlined at build time by Expo — never put secrets there. Secrets (your Stripe secret key, database password) live only on the server, in PandaStack's encrypted environment variables.
Step 5: The webhook endpoint everyone forgets
If you sell subscriptions through RevenueCat or the App Store, you need a server endpoint to receive events — a mobile app can't be trusted to report its own purchases. Add a route:
app.post('/webhooks/revenuecat', async (req, res) => {
const event = req.body.event
if (event?.type === 'INITIAL_PURCHASE') {
await sql`update users set pro = true where id = ${event.app_user_id}`
}
res.sendStatus(200)
})Because this runs on a real server with a stable URL and automatic SSL, you can paste https://your-app.pandastack.io/webhooks/revenuecat straight into the provider's dashboard.
Step 6: A scheduled cleanup job
Mobile apps accumulate junk: unverified accounts, expired magic links, orphaned uploads. A PandaStack cronjob is a container that runs on a schedule and exits — no idle server to pay for.
// cleanup.js — its own tiny image, run daily
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL, { ssl: 'require' })
const n = await sql`delete from users where verified = false and created_at < now() - interval '7 days'`
console.log(`pruned ${n.count} stale accounts`)
await sql.end()Create it under Cronjobs, set the schedule (0 3 * * * for 3am daily), attach the same database, done.
What about EAS Update / OTA?
For most teams, keep EAS Update — it's purpose-built and free at reasonable volume. You'd only self-host an OTA endpoint if you have compliance reasons to own the update channel or you've outgrown EAS pricing. If you go that route, the expo-updates protocol is just static JSON + asset serving, which PandaStack's static hosting handles — but that's an edge case, not the default. Don't build it until you need it.
Honest tradeoffs
- PandaStack doesn't build your iOS/Android binaries. EAS Build does that and does it well; this backend sits beside it, it doesn't replace it.
- Free-tier apps scale to zero and cold-start on the next request. For a background API that's usually fine; if your first request after idle feels slow, a paid tier keeps it warm.
- PandaStack is a newer platform than the incumbents — a plus for DX, worth knowing if you need a huge third-party ecosystem.
Wrap-up
Ship the app with Expo. Run the API, database, webhooks, and cron jobs on PandaStack, all wired together with an injected DATABASE_URL and Git-push deploys. That's the whole backend, and it fits in an afternoon.
Docs: https://docs.pandastack.io. Start free at https://dashboard.pandastack.io.