Firebase, Supabase, and similar tools let a client talk to the database directly, with a security rules engine as the only thing standing between a request and the data. No API layer, no server-side validation, nothing running that isn’t the browser and the vendor’s infrastructure. It’s a legitimate architecture. It’s also, for the wrong kind of app, a liability that doesn’t show up until someone opens the database client directly and reads things the UI was never going to show them.
The question that decides which side of that line an app is on isn’t “how big is the app” or “how much do I trust myself.” It’s narrower: how much of the access-control logic can actually be expressed as a declarative rule, versus how much needs real code to run somewhere.
What rules engines are and aren’t good at
A rules engine like Firestore’s evaluates a predicate against the request and the document being touched: is the requester authenticated, is the requester’s UID the same as a field on the document, is a field within some range. That’s genuinely enough for a large class of apps. What it can’t do:
- Rate limit beyond what a handful of requests-per-window primitives support
- Authorize based on a computation that spans more than the one document being read or written
- Transform or validate data before it’s considered trusted (a rule can reject a bad value, it can’t clean one up)
- Coordinate a write that touches two different owners’ data where each owner should only be trusted with their own side
If an app’s access control fits the first bullet’s shape entirely, “no backend” is not a shortcut, it’s just correct: a server that re-implements the same rule in application code adds a deploy target and a second place for the rule to drift out of sync with the rules file, without adding any actual security. If the access control needs any of the second list, “no backend” doesn’t fail loudly. It fails by quietly exposing whatever the rules engine couldn’t express, and that gap sits there until someone finds it.
A rules file that made this trade and says so
sd-tracker is a study-streak tracker: log a topic and a resource, watch a heatmap fill in, follow other people and see their streaks. It has no API routes and no server-side validation. The Next.js frontend talks to Firestore directly through the client SDK, and the entire access-control surface is this rules file:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid} {
allow read: if true;
allow write: if request.auth != null;
}
match /usernames/{username} {
allow read: if true;
allow write: if request.auth != null;
}
match /entries/{id} {
allow read: if true;
allow write: if request.auth != null;
}
match /following/{uid}/{document=**} {
allow read: if request.auth != null;
allow write: if request.auth.uid == uid;
}
match /followers/{uid}/{document=**} {
allow read: if request.auth != null;
allow write: if true;
}
}
}
Two lines are worth stopping on. entries allows read: if true, so anyone with a Firestore client, not just people using the app’s UI, can read every study entry in the collection. And followers/{uid} allows write: if true for anyone, not only the follower themselves, because the follow action needs to write into both the follower’s following subcollection and the target’s followers subcollection in a single client-side batch, and a rules predicate can’t express “the other party in this two-sided write consented to it.” Firestore rules evaluate one document at a time; they have no concept of a transaction’s other half.
From the project’s README: “
users,usernames, andentriesare readable by anyone with a Firestore client, not just through the app UI. It also means entry content (topic + resource text, not just the heatmap) is public for every account.”
That’s the second list from above showing up concretely: authorizing “only the two consenting parties in a follow relationship” and “only the entry’s owner, unless they opt in” both need logic that spans more than one document, which a rules predicate can’t hold. There’s no bug to point at here, the rules file does exactly what it was written to do. The cost is that the feature it enables (a public heatmap, a follow graph, both without a server) came bundled with entry content being public too, because the rules engine had no way to split “public heatmap” from “private content” without a second document and a second rule to go with it.
The data model didn’t have to know any of this
The entry shape predates the decision to use Firestore at all; it’s unchanged from when the app was localStorage-only:
// src/lib/firestore.ts
export interface StudyEntry {
id: string;
topic: string;
resource: string;
date: string;
createdAt: number;
userId: string;
}
Queries filter by userId and order by createdAt, which needs a composite index that Firestore won’t build automatically. With no server to own the query plan, that requirement surfaces directly to whoever’s running the app for the first time: Firestore returns an error with a link to create the index, rather than a backend catching it earlier in a migration step. That’s a minor cost of the no-backend choice, not a security one, but it’s the same pattern: work that a server would normally absorb quietly instead becomes visible at the edge, to whoever hits it first.
The same decision, compared directly
| Concern | With a backend | No backend (Firestore rules only) |
|---|---|---|
| Access control | Application code, testable in isolation | Declarative predicates, one document at a time |
| Cross-document authorization | Straightforward | Not expressible; falls back to if true or gets pushed elsewhere |
| Ops burden | A server to deploy, monitor, scale | None beyond the client and the vendor |
| Failure mode of a mistake | A bug in a route handler | A gap in a rules file, harder to unit test, easier to miss |
For sd-tracker, the worst case of the current rules file is a stranger finding out someone studied “Consistent Hashing” on a Tuesday. That’s a fine trade for a study tracker with no server to run. The fix, if it stopped being fine, is a schema change and not an architecture change: split entry metadata (date and count, which the heatmap needs) from entry content (topic and resource, which don’t need to be public) into separate documents with separate rules, so the heatmap stays public while the content defaults to private. The rules engine can express that split fine, it just needs the data modeled to match what it can and can’t say.
That’s the actual test worth applying before reaching for “no backend”: not whether the app is small, but whether every access-control decision it needs can be written as a predicate over one document at a time. If yes, skipping the server removes a deploy target for free. If no, the missing piece doesn’t disappear, it just moves into a rules file that was never designed to hold it.