Back to Tutorials

Firestore Rules for Expo Apps

Structure mobile data, protect it with rules, query it with indexes, and make screens feel steady when the network is not.

June 16, 2026 14 min read Paddy B
ExpoReact NativeFirestoreSecurity RulesOffline UX

The short version

Design paths around ownership, write rules before building screens, query through indexed collection paths, show cached data confidently, and test rules in the Firebase Emulator Suite before shipping.

Firestore lets Expo apps move fast because the client can read and write directly. That direct access is also why rules matter. Your app UI is a helpful suggestion; Firestore rules are the lock.

1. Pick a data shape that matches access

A practical starting point is nesting user-owned data below the user document. This keeps rules readable and makes it obvious who owns what.

users/{userId} users/{userId}/projects/{projectId} users/{userId}/projects/{projectId}/tasks/{taskId}

Use top-level collections when the data is shared, discoverable, or queried globally. Use subcollections when access is naturally scoped to a user, team, or parent record.

2. Start with ownership rules

For user-owned data, rules should check both authentication and path ownership.

rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function owns(userId) { return signedIn() && request.auth.uid == userId; } match /users/{userId} { allow read, create, update: if owns(userId); match /projects/{projectId} { allow read, create, update, delete: if owns(userId); match /tasks/{taskId} { allow read, create, update, delete: if owns(userId); } } } } }

This is intentionally simple. Add role checks later when the product needs them, not on day one because a role system feels more serious.

3. Validate writes in rules

Rules should reject impossible states. For example, a task title should exist, the status should be one of your supported values, and the owner should not be spoofable.

function validTask(userId) { return request.resource.data.keys().hasOnly([ "title", "status", "ownerId", "createdAt", "updatedAt" ]) && request.resource.data.ownerId == userId && request.resource.data.title is string && request.resource.data.title.size() > 0 && request.resource.data.status in ["todo", "doing", "done"]; }

Keep validation close to the data contract. Your React Native form can still show friendly errors, but Firestore should refuse invalid writes even from a modified client.

4. Query from Expo with explicit ordering

Most app screens should use a query with a stable order. It makes pagination easier and avoids jarring list movement.

import { collection, onSnapshot, orderBy, query, where } from "firebase/firestore"; import { db } from "./firebase"; export function subscribeToOpenTasks(userId: string, onChange: (tasks: Task[]) => void) { const tasksRef = collection(db, "users", userId, "projects", "main", "tasks"); const tasksQuery = query( tasksRef, where("status", "!=", "done"), orderBy("status"), orderBy("updatedAt", "desc") ); return onSnapshot(tasksQuery, (snapshot) => { onChange(snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() } as Task))); }); }

If Firestore asks for an index, create it and commit the generated firestore.indexes.json to the repo. Indexes are part of your app contract.

5. Design for weak connections

React Native users go through tunnels, lifts, low-power mode, and flaky office Wi-Fi. Make that normal.

  • Show cached records while fresh data is loading.
  • Disable destructive actions only when you truly cannot recover.
  • Use pending UI states for writes that have not reached the server yet.
  • Prefer small documents and focused listeners over one giant user document.
  • Keep timestamps server-generated for ordering that matters.

The Firebase JS SDK in React Native can cache useful state, but you should still make offline behavior obvious in the UI. A tiny "syncing" indicator beats a mysterious frozen screen.

6. Test with emulators

Run Auth and Firestore locally when you are building rules. You get quick feedback without risking production data.

firebase init emulators firebase emulators:start

In development, connect the app to the local emulators. On Android emulators, use 10.0.2.2 for the host machine; on a physical device, use your machine's LAN IP.

import { connectAuthEmulator } from "firebase/auth"; import { connectFirestoreEmulator } from "firebase/firestore"; import { auth, db } from "./firebase"; if (__DEV__) { connectAuthEmulator(auth, "http://127.0.0.1:9099"); connectFirestoreEmulator(db, "127.0.0.1", 8080); }

Production checklist

  • Deny by default, then open only the paths the app needs.
  • Write rules for create, update, and delete separately when the constraints differ.
  • Commit firestore.rules and firestore.indexes.json.
  • Run emulator tests for common user, stranger, and signed-out paths.
  • Watch document size and listener count before adding realtime updates everywhere.