Development
I stopped asking users to sign up
Most apps make you choose between signing in and signing up. I eventually changed my backend and authentication architecture so the front door became a single button, letting the server figure out whether you exist.
I stopped asking users to sign up
Most applications ask you one of two questions before you can do anything:
Do you want to sign in?
or:
Do you want to create an account?
I have always found this slightly strange.
When someone opens an app on their phone, they are not trying to manage an authentication state machine. They are just trying to use the software.
Asking them to pick between "Sign In" and "Sign Up" forces them to remember something about your database:
- "Did I create an account here six months ago?"
- "Did I sign up with Apple or Google?"
- "Was it my personal email or my work email?"
- "If I tap 'Sign Up' with an existing email, will the app yell at me with a red validation error?"
From a user perspective, whether an account already exists is an implementation detail.
Eventually, I changed my authentication and identity architecture so I could stop asking users to make that choice.
The front door became simple:
"Here is who I am. Continue."
Behind that single button, the backend took on the responsibility of figuring out whether that identity was an existing user logging in, a brand-new visitor being provisioned, or an anonymous guest upgrading their session.
Getting to that point was not a clean, one-day rewrite.
It involved starting on Firebase, moving core database responsibilities toward Appwrite, keeping Firebase where it actually earned its keep for mobile push notifications, and wrapping identity behind a boundary I controlled.
Firebase was the easy place to start
When I started building Shopmatey, Firebase was the obvious choice.
In my earlier essay on choosing FlutterFlow, I talked about how fast that initial setup was. Firebase removed nearly all the friction between having an idea and having a working prototype on a phone.
It gave me:
- out-of-the-box user authentication;
- turnkey OAuth integrations for Apple and Google;
- native mobile SDKs for iOS and Android;
- Firestore for document storage;
- Firebase Cloud Messaging (FCM) for push notifications.
When you are an indie developer building something new, that kind of velocity is priceless. You do not want to spend two weeks setting up authentication servers, session stores, and database migrations before you even know if anyone wants the app.
Firebase solved the immediate problem.
The friction only showed up later, when the application grew and I started caring about where the database ran, how costs scaled, and how tightly my frontend was coupled to a single vendor's SDK.
Appwrite entered the system gradually
At some point, I started setting up an Appwrite instance and mirroring data from Firebase.
I did not do a dramatic, overnight migration where I deleted Firebase and declared the stack pure. Real software projects rarely work that way, especially when you are running them alone.
Instead, the responsibilities shifted incrementally:
- Database and structured data: Appwrite became the primary home for user records, product catalogs, and application state. I liked the predictability of running it on my own infrastructure and having direct control over the database boundaries.
- Identity orchestration: User records were keyed to a canonical internal identifier stored in the database.
- Platform services: Firebase remained in place for things it handled exceptionally well, particularly push notification transport.
For a period, the architecture was less aesthetically neat than a diagram on a tech blog.
It had moving parts in both systems. But it allowed me to move database and backend control toward Appwrite without breaking existing users or rushing a risky all-at-once data migration.
Push notifications reminded me the stack was already distributed
One reason people get dogmatic about "migrating away from Firebase" is the belief that a good architecture should use exactly one vendor for everything.
Mobile push notifications quickly cure you of that fantasy.
Delivering a push notification to a smartphone is inherently a multi-system pipeline:
Application Backend (Appwrite / Server)
│
▼
Device Token Store
│
▼
Firebase Cloud Messaging (FCM)
╱ ╲
▼ ▼
Apple (APNs) Google (FCM Transport)
│ │
▼ ▼
iOS Device Android DeviceEven if you self-host your entire database and API, iOS notifications still have to pass through the Apple Push Notification service (APNs), and Android notifications rely on Google's transport infrastructure.
Firebase Cloud Messaging acts as a reliable, mature abstraction layer across those platform transports.
Ripping out a working FCM pipeline just to claim "100% vendor independence" would have cost days of development time without delivering a single byte of value to the person using the app.
Firebase stayed in the stack because it earned its place. Appwrite took over the database because that was where I needed ownership.
The stack became hybrid, but practical.
Authentication versus identity
The turning point was realizing that I had been confusing authentication with identity.
They sound like the same thing, but they solve different problems:
- Authentication is the proof: "Can this person prove they control this Apple ID, Google account, or email inbox?"
- Identity is the application entity: "Which internal user does this proof correspond to, and what data belongs to them?"
When you wire Firebase Auth or Appwrite Auth directly into your frontend screens, your user interface starts thinking in vendor primitives:
// The UI is tightly coupled to a specific provider
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
// Show Firebase login screen
}That coupling causes two problems:
- If you ever switch or add an identity provider, you have to rewrite every screen that touches user state.
- The user experience is forced to conform to the vendor's default mental model (separate login and registration endpoints).
I wanted the opposite.
I wanted the product to deal with a single, canonical application user. The identity layer should handle the providers, the database lookups, and the session resolution behind the scenes.
┌─ Apple ID
├─ Google
User Input ───────┼─ Email link / code
└─ Anonymous / Guest
│
▼
┌───────────────────────┐
│ Identity Boundary │
│ (Resolution & Auth) │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ Canonical User ID │
└───────────────────────┘
╱ ╲
▼ ▼
┌──────────┐ ┌──────────┐
│ Appwrite │ │ Firebase │
│ Database │ │ FCM │
│ & Files │ │ & Push │
└──────────┘ └──────────┘The single front door
Once identity was isolated behind an orchestration boundary, the login user experience changed completely.
Instead of presenting the user with two competing tabs:
[ Sign In ] | [ Create Account ]The screen simply presented one input:
[ Enter your email ]
[ Continue with Apple ]
[ Continue with Google ]When the user taps Continue, the backend executes an atomic resolution flow:
- Verify credential: Validate the incoming OAuth token or send a verification code to the email address.
- Lookup identity: Check the database to see if a user record is already bound to that verified identifier.
- Resolve state:
- If the user exists: generate a session and resume their account.
- If the user is new: bootstrap a fresh user record, assign a canonical user ID, and initialize default preferences.
- If the user was currently browsing as a guest: link the guest's existing local data and shopping cart to the newly verified account.
- Return session: Hand the authenticated session back to the client.
The user never sees an error saying:
"An account with this email already exists. Please go back and choose Sign In."
That error is a classic example of lazy architecture leaking into user interface design. The computer already knows the answer. Why make the human guess?
Simple for the user means complex for the backend
Making the front door effortless for the user required absorbing real complexity on the backend.
When you merge sign-in and sign-up into one flow, you have to handle edge cases that traditional forms push onto the user:
- Account collisions: What happens if someone logs in with Google using an email that was previously authenticated via Apple?
- Guest state preservation: If an anonymous user adds three items to a cart and then logs in, how do you cleanly migrate their temporary data without overwriting existing cloud state?
- Session invalidation: How do you revoke tokens reliably across devices when an account credential changes?
These are real engineering challenges.
The difference is where the friction lives. In a naive system, the friction is pushed onto the user through confusing forms and error messages. In a well-designed system, the backend does the heavy lifting so the user experience stays invisible.
Own your boundaries, not low-level primitives
A common trap for developers who want "control" is trying to build everything from scratch.
I want to be clear about what I built versus what I used:
- I did not write my own cryptography.
- I did not implement custom password hashing algorithms.
- I did not build a homegrown OAuth token exchange protocol.
Writing your own low-level security primitives is almost always a terrible idea. Proven platforms like Apple, Google, Firebase, and Appwrite have teams of security engineers dedicated to getting token cryptography and encryption right.
What I built was the orchestration boundary: the layer that maps external authentication tokens to internal user identities, resolves account state, and keeps product code isolated from vendor SDKs.
I do not need to own every tool in the stack.
I just need to own the contract between my product and those tools.
The broader takeaway
In the end, this evolution was the backend equivalent of what I later documented in moving away from FlutterFlow.
With FlutterFlow, I started with a visual abstraction because it got the product built quickly, and moved to raw Flutter code when I needed fine-grained control over the client.
With authentication, I started with default Firebase screens because they got users through the door, and moved to a centralized identity layer when I wanted a unified, low-friction onboarding experience.
Good architecture is not about picking the one perfect framework and using it forever.
It is about knowing what problem you are solving right now:
- Early on: eliminate friction between the idea and the prototype.
- Later on: eliminate friction between the user and the product.
When you stop asking users to care about your database state, the software feels better to use.
Everything behind that front door is just engineering.
Back to work.