DEV Community

Yusuf kızılkan
Yusuf kızılkan

Posted on

Everything your Flutter app needs after login (and why most starters stop there)

Every Flutter starter kit solves the login screen. Almost none of them solve what comes after it.

I spent the last few weeks building the "after" part for a real SaaS app, and it turned out to be six distinct problems — each one with a gotcha I didn't see coming. Here's what I learned, with the details that cost me the most time.

  1. Token refresh is a queueing problem, not a retry problem

The naive version: catch a 401, call /auth/refresh, retry the request. This works right up until your app fires three requests at once on a cold start. All three get a 401, all three call refresh, and now you have a race — two of them refresh with a token that's already been rotated server-side, and your user gets logged out for no reason.

The fix is to treat refresh as a single-flight operation. When a 401 arrives, check whether a refresh is already in progress. If it is, queue the request and wait for the result instead of starting a second refresh:

dart
Future _refreshOnce() {
// If a refresh is already running, everyone waits on the same future.
return _refreshFuture ??= _doRefresh().whenComplete(() {
_refreshFuture = null;
});
}

Small change, and it removes a whole class of "randomly logged out" bug reports.

  1. Your subscription state lives in two places, and they drift

If you use RevenueCat (or any IAP layer), the client knows whether the user is entitled. Your backend doesn't — until you tell it. And you need the backend to know, because the client can lie and because your server-side features need to gate on something.

The answer is the webhook. RevenueCat posts events to your API, you map the entitlement to a column, done:

python
@router.post("/webhooks/revenuecat")
async def revenuecat_webhook(request: Request, db: Session = Depends(get_db)):
verify_signature(request) # don't skip this
event = (await request.json())["event"]
user = db.query(User).filter(User.id == event["app_user_id"]).first()
user.subscription_status = (
"premium" if event["type"] in ACTIVE_EVENTS else "free"
)
db.commit()

Two things people get wrong here: not verifying the signature (your endpoint is public, anyone can POST to it), and treating the webhook as the only source of truth. Webhooks get delayed and dropped. Read entitlements on the client too, and let the backend column be the authoritative one for server-side checks.

  1. Push notifications need a device token table, not a user column

It's tempting to store fcm_token on the user row. Then your user installs the app on a tablet, and the phone stops getting notifications.

One user, many devices. Separate table, and handle token rotation — FCM rotates tokens on reinstall, restore, and sometimes for no visible reason:

python
class DeviceToken(Base):
tablename = "device_tokens"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"))
token = Column(String, unique=True, index=True)

Register on login, update on refresh, delete on logout.

  1. Account deletion is an app store requirement with a trap in it

Both stores now require in-app account deletion. What's less obvious: deleting the account does not cancel the subscription. The user deletes their account, keeps getting charged, and blames you.

So the flow has to be: check whether the user has an active subscription, and if they do, tell them plainly that they need to cancel through the store as well. Then soft-delete rather than hard-delete — you want the row for billing reconciliation, but you want the personal data gone:

python
user.deleted_at = datetime.utcnow()
user.is_active = False
user.email = f"deleted-{user.id}@example.invalid"
user.full_name = None
user.avatar_url = None

  1. Your starter should run before any API keys exist

This one is a design decision rather than a bug, and it changed how the whole project felt to work on.

If your app crashes without google-services.json, then nobody — including future you — can clone it and see it run. So every external integration got a mock path: no Firebase credentials means push logs a warning and returns a mock response instead of throwing. No RevenueCat key means the paywall renders with fake products.

python
if not credentials_path and not credentials_json:
logger.warning("Firebase credentials not set — push running in mock mode")
return {"mode": "mock", "recipients": 0}

The result: docker compose up, flutter run, and you have a working app in two minutes. You wire the real services when you actually need them.

  1. You will want an admin panel sooner than you think

The first time a user emails "I can't log in", you'll be writing SQL by hand. If you're on FastAPI, sqladmin gives you a usable panel in an afternoon — user list, search, ban/activate — and you can secure it with the JWT auth you already built instead of inventing a second login system.

Worth adding from day one: a filter for soft-deleted users (otherwise they clutter every list) and a stats page. Not because the numbers are sophisticated, but because "how many people signed up yesterday" is the question you'll ask most often.

The Windows gotcha that ate an evening

Unrelated to any of the above, but it cost me hours and almost nothing is written about it: if your Windows user folder is localized — mine is Masaüstü — Gradle and Flutter's shader compiler fail on the non-ASCII character. The error message points nowhere near the actual cause.

Workaround: subst a drive letter to your project path, or move the project somewhere ASCII-only. Also, recent flutter_secure_storage versions need minSdk 23, which produces its own confusing build failure.

If you'd rather not build all of this

I packaged everything above into a starter kit — Flutter app plus FastAPI/PostgreSQL backend, with the auth, subscriptions, push, profile management, i18n and admin panel already wired together and tested end to end.

Free auth module (MIT, just the login layer): github.com/yusufkizilkan/flutter-fastapi-auth-starter
Full kit: kizilkan2.gumroad.com/l/flutter-saas-kit

But honestly, the six problems above are worth understanding whether or not you use someone else's code for them. Most of my time went into the parts nobody writes tutorials about.

What did I miss? If you've shipped a Flutter SaaS, I'd like to hear which of these bit you hardest.

Top comments (1)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

ordering is the one i'd add. delivery isn't ordered, so a delayed cancellation landing after a renewal writes free over a sub that's active, and a redelivery replays old state. treating the event as a signal to go refetch the subscriber and writing whatever entitlement you read back kills that whole class, or failing that, compare the event's own timestamp against the last one you applied and drop anything older. also filter(User.id == event["app_user_id"]) assumes those two ids match, and a purchase made pre-login gives you an anon id that gets aliased later, so .first() comes back None and the handler 500s into a retry loop.