Most AI-generated apps look great in a demo and fall apart in production. This is the story of building one that doesn't — and everything I learned catching the ways it almost didn't.
Reflect isn't a chat wrapper with a journal skin. It's a persistent companion that builds long-term psychological memory over time — combining an asynchronous memory synthesis layer with on-demand thematic insight extraction, daily gratitude tracking, and proactive check-in nudges, so patterns in how you think and feel become visible instead of scattered across dozens of disconnected entries.
The starting constraint
The brief was simple on paper: build an authenticated app, wire up Gemini and Firestore, isolate every user's data, never let a secret touch the client — all inside Google AI Studio, configuring its Custom Instructions to enforce threat modeling and secure coding before a single feature existed.
That constraint shaped everything that came after. Every later addition — gratitude tracking, data export, account deletion — inherited the same UID-scoped isolation and server-side secret handling by default, because the rules were load-bearing from day one, not retrofitted at the end.
What it actually does
The part most build logs skip
The features are the easy part to write about. What's more useful is what actually broke — because every one of these was a real moment where "looks done" and "is done" turned out to be different claims.
The response that was never there
Early on, a journal entry would sit in a loading state indefinitely. The network request wasn't failing — it simply never resolved. A background memory-synthesis call was silently blocking the user-facing reply from ever starting. The fix informed the architecture shown below: memory updates run genuinely asynchronously, never gating the conversational response.
The reply that was always the same
Later, every entry — regardless of content — returned
identical reflection text. A fail-safe fallback, added to guarantee
the UI never showed a blank card, had quietly become the
default path instead of the exception. That's part of why
the current architecture uses an explicit multi-model fallback
chain (gemini-3.1-flash-lite →
gemini-3.7-flash → gemini-flash-latest)
with visibility into which tier actually responded, rather than a
single silent catch-all.
The moment that mattered most
Mid-build, GitHub's secret scanning flagged a live Google API key
committed to the public repository. It was rotated within the
hour, the repo history was cleaned, and the codebase was
re-audited to confirm nothing else was exposed — the
GEMINI_API_KEY now lives exclusively in Secret
Manager, retrieved server-side only.
We're including this on purpose. A security incident that gets caught, disclosed, and fixed quickly says more about real security practice than a repo that simply never mentions the possibility.
Most of these bugs shared one pattern: the network request succeeded, but the UI didn't reflect what actually happened underneath. A 200 status code is not the same claim as a correct response.
What it's built on
Firebase Authentication handles Google Sign-In with no passwords
ever stored. Cloud Firestore holds every user's entries, gratitude
logs, memory summary, insights, and nudges — all strictly
UID-scoped, with security rules that deny everything outside a
user's own path by default. An Express.js API layer on Cloud Run
orchestrates every Gemini call through @google/genai,
with a three-tier model fallback for resilience, and the API key
retrieved from Google Cloud Secret Manager at runtime —
never shipped to the client.
Firestore isolation rule
match /users/{userId}/{document=**} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
match /{document=**} { allow read, write: if false; }
Every collection follows the same pattern:
users/{uid}/entries, users/{uid}/gratitude,
users/{uid}/profile/summary,
users/{uid}/insights, and
users/{uid}/nudges — no exceptions, no shared
collections, no cross-tenant reads possible.