Firebase Emulator Suite for Expo React Native
The Firebase Emulator Suite runs Auth, Firestore, Cloud Functions, and Storage on your own computer. For an Expo app, that means you can sign up fake users, break your security rules, and call functions as often as you like without touching real data or your bill. Most setups stall at the same point: getting a phone or an Android emulator to reach the emulators at all. This guide covers the setup, the hostnames, seed data, rules tests, CI, and the guards that stop a development session from writing to production.
The short version
Use a project ID that starts with demo-, so anything that isn't emulated fails instead of reaching a real project. Only connect the app to the emulators when both __DEV__ and an environment variable say so, so a release build never can. The iOS Simulator reaches the emulators at 127.0.0.1 and the Android emulator at 10.0.2.2. Physical devices need the emulators listening on 0.0.0.0 and your computer's LAN IP. Seed data with a script, test rules with @firebase/rules-unit-testing, and remember that the Firestore emulator doesn't check composite indexes.
Versions used, as of September 18, 2026
Firebase CLI (firebase-tools) 15.30, Firebase JS SDK 12.19, @firebase/rules-unit-testing 5.0, and Expo SDK 57. firebase-tools 15 needs Node 20 or newer, plus Java 21 or newer for the Firestore, Storage, and Realtime Database emulators. Some Firebase docs pages still say Java 11, but the CLI refuses to start those emulators on anything older than 21.
1. Install the CLI and pick a demo project
Install the Firebase CLI as a dev dependency so everyone on the project, and CI, runs the same version:
npm install --save-dev firebase-tools
npx firebase init emulatorsSelect Authentication, Firestore, Functions, Storage, and the Emulator UI. The emulators read rules from the firestore and storage settings in firebase.json. If your project doesn't have firestore.rules, storage.rules, or a functions folder yet, run npx firebase init firestore functions storage first.
Check Java before you go further. Auth and Functions run on Node, but the Firestore and Storage emulators are Java programs:
java -version # needs 21 or newerThen choose a project ID. Firebase has demo projects: any project ID that starts with demo-. A demo project has no real configuration and no live resources, so the emulators are the only thing it can talk to. If your code calls a service you haven't started, it fails instead of writing to a real database. You don't even create it in the console. This guide uses demo-shop.
Use the same ID everywhere: the CLI, the app, the seed script, and the Functions code. Cross-service features, like Firestore rules that read request.auth or functions triggered by Firestore writes, only work when the project IDs match.
2. Configure and start the emulators
firebase init emulators writes an emulators block into firebase.json. With the default ports it looks like this:
// firebase.json (emulators block)
{
"emulators": {
"auth": { "port": 9099 },
"functions": { "port": 5001 },
"firestore": { "port": 8080 },
"storage": { "port": 9199 },
"ui": { "enabled": true, "port": 4000 }
}
}Add scripts for starting the emulators and for starting Expo against them:
// package.json
{
"scripts": {
"emulators": "firebase emulators:start --project demo-shop --import ./emulator-data --export-on-exit",
"start:emulators": "EXPO_PUBLIC_USE_EMULATORS=1 expo start --clear"
}
}--import loads saved data at startup, and --export-on-exit saves it back to the same folder when you press Ctrl+C. The CLI stops with an error if the import folder doesn't exist, so create it once and keep it out of git:
mkdir emulator-data
echo "emulator-data/" >> .gitignore
npm run emulatorsThe Emulator UI is at http://127.0.0.1:4000. It shows your users, documents, stored files, and function logs, and you can edit data there by hand. The CLI also prints Detected demo project ID "demo-shop". That line confirms you're safely isolated. The --clear in start:emulators is there because Expo writes EXPO_PUBLIC_ values into the bundle when it builds it, and clearing Metro's cache makes sure the switch takes effect.
3. Point the Expo app at the emulators
This extends the Firebase module from Firebase Auth in Expo React Native. Everything still happens in one file, and the emulator connections happen straight after each service is created, before anything else can use it:
// lib/firebase.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { Platform } from "react-native";
import { getApps, initializeApp } from "firebase/app";
import { connectAuthEmulator, getReactNativePersistence, initializeAuth } from "firebase/auth";
import { connectFirestoreEmulator, getFirestore } from "firebase/firestore";
import { connectFunctionsEmulator, getFunctions } from "firebase/functions";
import { connectStorageEmulator, getStorage } from "firebase/storage";
// Both must be true, so a release build never connects even if the variable leaks into one
export const usingEmulators = __DEV__ && process.env.EXPO_PUBLIC_USE_EMULATORS === "1";
const firebaseConfig = usingEmulators
? {
apiKey: "demo-api-key",
projectId: "demo-shop",
storageBucket: "demo-shop.appspot.com",
appId: "demo-app",
}
: {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
export const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig);
export const auth = initializeAuth(app, {
persistence: getReactNativePersistence(AsyncStorage),
});
export const db = getFirestore(app);
export const functions = getFunctions(app, "europe-west2");
export const storage = getStorage(app);
if (usingEmulators) {
const host = emulatorHost();
connectAuthEmulator(auth, `http://${host}:9099`);
connectFirestoreEmulator(db, host, 8080);
connectFunctionsEmulator(functions, host, 5001);
connectStorageEmulator(storage, host, 9199);
console.info(`[firebase] Using emulators at ${host} (demo-shop)`);
}
function emulatorHost() {
if (process.env.EXPO_PUBLIC_EMULATOR_HOST === "lan") {
// Metro's address, like "192.168.1.20:8081". Only present in development.
const lanHost = Constants.expoConfig?.hostUri?.split(":")[0];
if (lanHost) return lanHost;
}
// The iOS Simulator shares your computer's localhost; the Android emulator reaches it at 10.0.2.2
return Platform.OS === "android" ? "10.0.2.2" : "127.0.0.1";
}The demo config does more than name the project. The Auth emulator accepts any API key, and the JS SDK includes the API key in the name it stores the signed-in user under. So emulator users and real users are saved separately in AsyncStorage. Switching modes won't leave you signed in with a token the other side rejects.
Pass the same region to getFunctions that your functions use. The emulator serves each function under its region, just like production, and the JS SDK builds the URL from the region you give it.
4. Simulators, emulators, and physical devices
127.0.0.1 always means "this device", which is why the right host depends on where the app is running:
- iOS Simulator: shares your Mac's network, so
127.0.0.1works. - Android emulator:
127.0.0.1is the emulator itself. Your computer is at10.0.2.2. - Physical device, iOS or Android, in Expo Go or a development build: needs your computer's LAN IP, on the same Wi-Fi network.
A physical device needs one more change. By default the CLI binds every emulator to localhost, which nothing else on the network can reach. Bind them to all interfaces instead:
// firebase.json
{
"emulators": {
"auth": { "host": "0.0.0.0", "port": 9099 },
"functions": { "host": "0.0.0.0", "port": 5001 },
"firestore": { "host": "0.0.0.0", "port": 8080 },
"storage": { "host": "0.0.0.0", "port": 9199 },
"ui": { "enabled": true, "port": 4000 }
}
}EXPO_PUBLIC_USE_EMULATORS=1 EXPO_PUBLIC_EMULATOR_HOST=lan npx expo start --clearWith EXPO_PUBLIC_EMULATOR_HOST=lan, the app takes the host from Constants.expoConfig.hostUri, which is the address Metro serves your bundle from. If the phone can load your code, it can reach the emulators on the same IP. This doesn't work with expo start --tunnel: hostUri becomes the tunnel's hostname, and the emulators aren't tunneled.
0.0.0.0 opens the emulators to your whole network
Anyone on the same Wi-Fi can then read and write your emulated data and call your emulated functions. Only do this on networks you trust. That's one more reason to use a demo project: there's nothing real behind it.
Plain HTTP is allowed in development only
The emulators serve plain HTTP. On Android, Expo's native template allows unencrypted traffic only in the debug build variant, which covers development builds and Expo Go. A preview or production build can't connect to http:// addresses, so a misconfigured release build fails instead of quietly using the emulators. On iOS, the template sets NSAllowsLocalNetworking, which allows HTTP to addresses on your local network.
5. Seed development data
You could create test data by hand in the app and let --export-on-exit save it. That works for you, but not for a teammate or CI. A seed script is better, because it's reviewable, repeatable, and safe to run again:
npm install --save-dev firebase-admin// scripts/seed.mjs
import { initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
const projectId = "demo-shop";
const authHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
const firestoreHost = process.env.FIRESTORE_EMULATOR_HOST;
// Without these, the Admin SDK would talk to real Firebase
if (!authHost || !firestoreHost) {
throw new Error("Set FIREBASE_AUTH_EMULATOR_HOST and FIRESTORE_EMULATOR_HOST first");
}
// Start from empty, so the script can run again
await fetch(`http://${authHost}/emulator/v1/projects/${projectId}/accounts`, { method: "DELETE" });
await fetch(
`http://${firestoreHost}/emulator/v1/projects/${projectId}/databases/(default)/documents`,
{ method: "DELETE" },
);
initializeApp({ projectId });
const auth = getAuth();
const db = getFirestore();
for (const user of [
{ uid: "alice", email: "alice@example.com", displayName: "Alice" },
{ uid: "bob", email: "bob@example.com", displayName: "Bob" },
]) {
await auth.createUser({ ...user, password: "password123", emailVerified: true });
await db.doc(`users/${user.uid}`).set({
displayName: user.displayName,
createdAt: FieldValue.serverTimestamp(),
});
}
await db.doc("users/alice/projects/launch").set({ name: "App launch" });
await db.doc("users/alice/projects/launch/tasks/icons").set({ title: "Export app icons", status: "open" });
console.log("Seeded demo-shop. Sign in as alice@example.com or bob@example.com / password123");// package.json
"seed": "FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node scripts/seed.mjs"Run npm run seed while the emulators are running. The two environment variables are what point the Admin SDK at the emulators, so the script refuses to run without them. The two DELETE calls use the emulators' reset endpoints, which production doesn't have. The data follows the users/{uid}/projects/{projectId}/tasks shape from Firestore rules and offline data.
The Auth emulator never sends real email. Verification and password reset links show up in the emulator's terminal output instead, and you can open them from there.
6. Test Firestore rules locally
Clicking through the app tells you your rules let the right person in. It doesn't tell you they keep everyone else out. Rules tests do both, and they run in seconds against the Firestore emulator:
npm install --save-dev vitest @firebase/rules-unit-testing// tests/rules/firestore.test.ts
import { readFileSync } from "node:fs";
import { afterAll, beforeAll, beforeEach, describe, it } from "vitest";
import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
type RulesTestEnvironment,
} from "@firebase/rules-unit-testing";
let env: RulesTestEnvironment;
beforeAll(async () => {
env = await initializeTestEnvironment({
projectId: "demo-shop-test",
firestore: { rules: readFileSync("firestore.rules", "utf8") },
});
});
beforeEach(async () => {
await env.clearFirestore();
await env.withSecurityRulesDisabled(async (ctx) => {
await ctx.firestore().doc("users/alice").set({ displayName: "Alice" });
});
});
afterAll(() => env.cleanup());
describe("users/{userId}", () => {
it("lets a user read their own profile", async () => {
const db = env.authenticatedContext("alice").firestore();
await assertSucceeds(db.doc("users/alice").get());
});
it("stops a user reading someone else's profile", async () => {
const db = env.authenticatedContext("bob").firestore();
await assertFails(db.doc("users/alice").get());
});
it("stops signed-out reads", async () => {
const db = env.unauthenticatedContext().firestore();
await assertFails(db.doc("users/alice").get());
});
it("stops a user writing tasks under someone else", async () => {
const db = env.authenticatedContext("bob").firestore();
await assertFails(db.doc("users/alice/projects/launch/tasks/x").set({ title: "Hi" }));
});
});Each context.firestore() returns Firebase's older namespaced Firestore API, which is why these tests call .doc().get() rather than the modular functions your app uses. authenticatedContext creates a signed-in user without the Auth emulator, so these tests only need Firestore.
The tests use their own project ID, demo-shop-test. The emulator keeps each project's data separate, so clearFirestore() wipes the test data and leaves your seeded development data alone. There are two ways to run them:
# One-off and CI: starts a fresh Firestore emulator, runs the tests, shuts it down
npx firebase emulators:exec --only firestore --project demo-shop-test "npx vitest run tests/rules"
# While npm run emulators is already running: watch mode against it
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 npx vitest tests/rulesUse the second form while you write rules. The first one would try to start a second Firestore emulator on port 8080 and fail. The emulator logs a warning about more than one project ID, which is expected here. While the emulator is running, open http://127.0.0.1:8080/emulator/v1/projects/demo-shop-test:ruleCoverage.html to see which rule expressions your tests actually exercised.
If the app also runs Jest with jest-expo, add /tests/rules/ to testPathIgnorePatterns. These tests need Node and a running emulator, not the React Native test environment. Storage rules work the same way with a storage entry in initializeTestEnvironment. The rules from Firebase Storage uploads are a good place to start.
7. Keep production out of it
Most emulator accidents are one of these four:
- A real project ID. If the CLI runs against your real project, anything you forgot to emulate is real. Use
demo-shop, and check for the Detected demo project ID line at startup. - A switch that ships. Keep
EXPO_PUBLIC_USE_EMULATORSin the npm script, not in a.envfile an EAS build could pick up. The__DEV__check means a release build ignores it anyway. EAS Build environment variables covers what each build profile gets. - Functions reaching unemulated services. The Admin SDK inside the Functions emulator uses whichever emulators are running and falls back to the real service for the rest. The CLI warns you, for example: "The Cloud Firestore emulator is not running, so calls to Firestore will affect production." Start every emulator your functions touch. With a demo project, those calls fail instead.
- Real secrets and third-party APIs. By default the Functions emulator reads production secrets using your local Google credentials. Override them in
functions/.secret.local. Anything your functions call outside Firebase is still real, including the Expo push service, email, and payments.
For that last one, the Functions emulator sets FUNCTIONS_EMULATOR to "true". Check it before any call with a real-world effect:
// functions/src/push.ts
import { logger } from "firebase-functions";
const inEmulator = process.env.FUNCTIONS_EMULATOR === "true";
type PushMessage = { to: string; title?: string; body?: string };
export async function sendExpoPush(messages: PushMessage[]) {
if (inEmulator) {
logger.info("Emulator: skipping Expo push send", { count: messages.length });
return [];
}
// ...POST to https://exp.host/--/api/v2/push/send
}That keeps the flow from Firebase Functions for Expo push notifications testable without buzzing real phones.
8. What the emulators won't tell you
The emulators are close to production, but Firebase lists some differences that matter for mobile apps:
- Composite indexes. The Firestore emulator runs any valid query. A query that needs a composite index works locally and fails in production until the index exists. Deploy
firestore.indexes.jsonand run your queries against a real staging project before release. - Limits and transactions. Not every production limit is enforced, and contended transactions behave differently. Firebase notes that locks can take up to 30 seconds to release.
- Auth abuse protection. The Auth emulator has no rate limits and sends no email or SMS.
- Services with no emulator. App Check, Remote Config, Analytics, Crashlytics, and push delivery all need a real project. App Check in particular needs testing against real enforcement.
- The Functions runtime. Your functions run in your local Node, not in Google's production containers, so timing, memory, and the Node version can all differ.
Treat the emulators as the place you prove behavior and rules. A staging Firebase project is still the place you prove indexes, App Check, and deployment.
9. Run the rules tests in CI
Because the tests use a demo project, CI needs no Firebase credentials or service account. It just needs Node, Java 21, and your dev dependencies:
# .github/workflows/firebase-rules.yml
name: Firebase rules
on:
pull_request:
push:
branches: [main]
jobs:
rules:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- uses: actions/cache@v6
with:
path: ~/.cache/firebase/emulators
key: firebase-emulators-${{ hashFiles('package-lock.json') }}
- run: npm ci
- run: npx firebase emulators:exec --only firestore --project demo-shop-test "npx vitest run tests/rules"The CLI downloads the emulator binaries into ~/.cache/firebase/emulators on first use, and the cache step saves that download on later runs. Keying the cache on the lockfile means a new CLI version downloads fresh binaries. emulators:exec exits with your test command's exit code, so a failing rule fails the pull request. If you already deploy Hosting from Actions, add this job next to the one in Firebase Hosting with GitHub Actions.
Firebase Emulator Suite FAQ
Why can't my phone connect to the Firebase emulators?
By default the emulators only listen on localhost, which a physical device can't reach. Set host to 0.0.0.0 for each emulator in firebase.json, connect the app to your computer's LAN IP instead of 127.0.0.1, and keep the phone on the same network. Simulators are different: the iOS Simulator can use 127.0.0.1, and the Android emulator reaches your computer at 10.0.2.2.
Do I need a real Firebase project to use the emulators?
No. Use a project ID that starts with demo-, such as demo-shop. Demo projects have no live resources, so the emulators are the only thing your code can reach, and anything that isn't emulated fails instead of touching a real project. Use the same ID in the CLI, the app config, seed scripts, and tests.
Why does a Firestore query work in the emulator but fail in production?
The Firestore emulator doesn't track composite indexes. It runs any valid query, so a query that needs an index succeeds locally and fails in production until the index exists. Deploy firestore.indexes.json and test your queries against a real staging project before release.
Do the Firebase emulators work with Expo Go?
Yes, for services you use through the Firebase JS SDK: Auth, Firestore, Cloud Functions, and Storage. The JS SDK needs no native code, so its connect emulator functions work in Expo Go and in development builds. On a physical device you still need the LAN IP setup.
10. Production checklist
firebase-toolsinstalled as a dev dependency, with Java 21 or newer locally and in CI.- One
demo-project ID shared by the CLI, the app, the seed script, and Functions, plus a separate one for rules tests. - The app only connects when
__DEV__andEXPO_PUBLIC_USE_EMULATORSare both set, and the variable lives in an npm script, not a.envfile. - The right host for each device:
127.0.0.1,10.0.2.2, or the LAN IP with emulators bound to0.0.0.0on a trusted network. emulator-data/in.gitignore; the seed script committed and safe to run twice.- Rules tests for the owner, another user, and a signed-out user on every collection.
- Rules tests kept out of the
jest-exporun. - Every service your functions touch has its emulator running.
- Functions skip real third-party calls when
FUNCTIONS_EMULATORis"true", and secrets are overridden in.secret.local. - Composite indexes deployed and queries checked against a staging project before release.
- CI runs the rules tests on every pull request, with no Firebase credentials.
Sources
Firebase: Install and configure the emulators | Firebase: Connect to the Firestore emulator | Firebase: Connect to the Auth emulator | Firebase: Connect to the Functions emulator | Firebase: Build rules unit tests | firebase-tools v15.0.0 release notes | Expo: expo-constants | Expo: Using Firebase
Related tutorials
Need this built for you? I take on contract Expo and React Native work — see how I can help.