Firestore Reads & Writes From React: The Client SDK vs. a Backend API

Personal Catalogue: React & Firebase

Chapter 3 · Firestore Reads & Writes From React: The Client SDK vs. a Backend API

Two chapters have now written directly to Firestore from plain scripts and left one real question hanging both times: does this app ever get a backend server of its own, or does React talk to Firestore directly for good? This chapter answers it, for real, and builds the one piece that answer actually depends on.

A Confession About the Last Two Chapters' Own Code

Chapter 1 said to enable Firestore in production mode, specifically because test mode leaves every document wide open. What that also means, honestly: if the addDoc/getDocs code from Chapters 1 and 2 was actually run against a real production-mode project, every single call would have failed. Firestore's own real production-mode default is a deny-all rule:

// firestore.rules — the real default in production mode rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if false; } } }

Nothing about that is a mistake in either earlier chapter — the code shown was always correct, and this chapter is what actually turns it on.

Resolving the Fork: No Backend Server of This Course's Own

Chapter 1's own finding held up: nothing in this app's real v1 scope needs a secret, and nothing needs a Cloud Function to hold one. Given that, introducing a small Express-style API layer here would buy back exactly nothing this variant doesn't already get from the client SDK alone — it would just reintroduce the self-run server this course exists specifically to do without. So the real decision: React talks to Firestore directly, using the client SDK, for every read and write this app makes. There is no backend process of this course's own at all.

That decision puts real weight somewhere else, though — Chapter 2's own warn-box already named it. With no server standing between the browser and the database, validateItem() alone was never going to be enough on its own; it's a function, not a gate anyone is forced to pass through. Something has to actually enforce it. That something is Firestore Security Rules.

Turning Chapter 2's Checks Into Real Rules

Security Rules are evaluated by Firebase's own servers on every single request, regardless of what code sent it — a script, a browser tab, or a stray line typed straight into the Firebase console. Translated directly from Chapter 2's own validateItem() logic:

// firestore.rules rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function hasValidBaseFields(data) { return data.title is string && data.title.size() > 0 && data.itemType in ['Book', 'Cd', 'Dvd', 'Bluray']; } function hasTypeRequiredFields(data) { return (data.itemType != 'Book' || (data.author is string && data.author.size() > 0)) && (data.itemType != 'Cd' || (data.artist is string && data.artist.size() > 0)); } function hasValidResolution(data) { return data.itemType != 'Bluray' || !('resolution' in data) || data.resolution in ['1080p', '4K']; } match /items/{itemId} { allow read: if true; allow write: if hasValidBaseFields(request.resource.data) && hasTypeRequiredFields(request.resource.data) && hasValidResolution(request.resource.data); } } }

Deployed with firebase deploy --only firestore:rules, this is the exact same rule set the MongoDB sibling's own discriminator schemas enforce, expressed as declarative rules instead of JavaScript classes — and, critically, applied by Firestore itself rather than by any one function someone has to remember to call.

Proving It — Including on Purpose Skipping validateItem()

A valid write, with validateItem() called first exactly as Chapter 2 intended:

import { collection, addDoc } from "firebase/firestore"; import { db } from "./firebase"; import { validateItem } from "./validateItem"; async function addItem(data) { validateItem(data); // throws instantly, no network round trip, if data is malformed return addDoc(collection(db, "items"), data); } await addItem({ itemType: "Cd", title: "Discovery", artist: "Daft Punk" }); // succeeds — validateItem() passes, then Firestore's own rules pass too

Now the deliberate skip — calling addDoc directly, exactly the "a different code path forgot to validate" scenario Chapter 2's own warn-box raised:

await addDoc(collection(db, "items"), { itemType: "Cd", title: "Discovery" }); // no artist, and validateItem() was never called at all
// Real output FirebaseError: Missing or insufficient permissions.
The Gap Chapter 2 Left Open Is Closed
That rejection has nothing to do with validateItem() — it never ran. Firestore itself refused the write because hasTypeRequiredFields() evaluated to false against the real document being sent. This is the universal guarantee Mongoose discriminators give the MongoDB sibling for free, now genuinely present here too — just enforced by rules instead of by a schema class.

What Security Rules Still Can't Do Here

allow read: if true above is a deliberate, honest choice, not an oversight. This app has no Firebase Authentication, no user accounts, and no login of any kind — a decision this course made back in Chapter 1, in keeping with a genuinely personal, single-user tool. Firestore's own request.auth variable, the normal way a rule checks who is making a request, is always null here, for every request, because no one has ever signed in through this app at all.

Rules Check Shape, Not Identity — In This App, By Design
Every rule written above checks what a document looks like, never who's sending it. That's a real, genuine limit of Security Rules without an Authentication layer sitting underneath them — and it's the correct tradeoff for a tool built for exactly one person, deployed somewhere only that person knows the URL for. It would stop being the right tradeoff the moment this app gained real user accounts, or was deployed somewhere its address might reasonably leak — at which point rules gating on request.auth.uid, the same mechanism the site's own Food Tracker (React + Firebase) course reaches for once it adds Authentication, would become necessary here too.

The Architecture, Now Fully Resolved

LayerMongoDB siblingThis course
Where a request lands firstAn Express route, running on a server this project starts and keeps runningFirestore itself — React calls the client SDK directly, no server of this course's own
Shape validationMongoose discriminator schemas, checked in the Express processFirestore Security Rules, checked by Firebase's own servers
Instant client-side feedbackWhatever the frontend chooses to check before sending the requestvalidateItem(), called before every write — a UX convenience, not the real gate
The real, universal gateThe Express route + Mongoose, togetherSecurity Rules alone
Who can writeWhoever can reach the Express server (no auth built for this project either)Anyone who can reach the Firestore project — no request.auth check exists to make

Every remaining chapter in this course builds directly against this: React components calling validateItem() then a Firestore SDK function, with the Security Rules deployed here standing permanently behind them.

Hands-On Exercises

Exercise 1

Deploy the Security Rules above to a real Firestore project (or the local emulator), then call addDoc directly — bypassing validateItem() entirely — with a Book document that has no author. Confirm the write is rejected and record the real error message.

📄 View solution
Exercise 2

Explain why allow read: if true is a reasonable choice for this specific app today, and name the exact condition under which it would stop being reasonable.

📄 View solution
Exercise 3

Explain what real value validateItem() still adds once Security Rules are deployed, given that the rules alone are now the actual, universal gate — in other words, why wasn't Chapter 2's own work wasted effort now that Chapter 3 exists?

📄 View solution

Chapter 3 Quick Reference

  • Resolved — no backend server of this course's own; React talks to Firestore directly via the client SDK
  • Why — nothing in v1 scope needs a secret (Chapter 1's own finding), so a server would add cost with no real benefit
  • The real gate — Firestore Security Rules, translated directly from Chapter 2's own validateItem() logic, enforced by Firebase itself regardless of what code is writing
  • Production mode's real default — deny-all until rules like these are deployed; earlier chapters' own code only starts working now
  • validateItem()'s real remaining job — instant, round-trip-free feedback in the UI; a convenience layered on top of the real gate, not a replacement for it
  • Honest limit — rules here check document shape only, never who's writing, since this app has no Authentication layer at all
  • Next chapter: The Add-Item Form — type-specific fields and client-side validation