You built a Flutter web app, added invite links, pasted one into WhatsApp — and got a generic title, no image, maybe even the wrong description. Meanwhile your database already has everything needed for a beautiful preview card.

Annoying? Absolutely. Hard to fix? Kind of — if you only look at [.c]index.html[.c].

TL;DR — intercept invite URLs with a Firebase Hosting rewrite, serve a tiny HTML page with dynamic OG tags from your database, then bootstrap Flutter. No redirect. Crawlers happy, users happy.

The single [.c]index.html[.c] problem

Flutter web ships one static [.c]index.html[.c]. Every route — [.c]/[.c], [.c]/settings[.c], [.c]/invite/abc123[.c] — eventually resolves to that same file, typically via a catch-all rewrite. That works fine for SPAs in the browser.

Link preview bots behave differently.

WhatsApp, iMessage, Slack, LinkedIn, Telegram — they fetch your URL, parse HTML, read [.c]og:title[.c], [.c]og:description[.c], [.c]og:image[.c], and leave. They do not run your Dart code, they do not wait for [.c]flutter_bootstrap.js[.c], and they certainly do not call Firestore.

So if your [.c]index.html[.c] looks like this:

…every invite link preview will look identical, no matter how much context lives in your backend.

You could pre-generate static HTML per invite — doable, but a headache to maintain. You could add a separate SSR stack — overkill for a mobile-first product with a lightweight web companion.

We wanted something nimble: dynamic OG meta, fetched at request time, based on the URL path.

The workaround: one route, one Cloud Function, same Flutter app

The idea is simple. When a user opens [.c]https://app.example.com/invite/some-id[.c] in a browser, they get HTML that loads Flutter, the app reads the path, and shows the invite screen — normal SPA behavior. When a crawler hits the same URL, Firebase Hosting routes [.c]/invite/**[.c] to a Cloud Function that returns HTML with custom OG tags pulled from the database.

Same URL, two different consumers, and one function sitting in the middle to make both of them happy.

Wire it up in [.c]firebase.json[.c] — the invite rewrite needs to sit above the catch-all, otherwise every [.c]/invite/...[.c] request just gets the static [.c]index.html[.c], which is exactly the problem we're solving:

Parsing the path, fetching the meta

The function receives the full request path. We parse the last segment as an ID, look it up in Firestore, and build OG values from whatever fields make sense for your domain.

App Experts
Hire Us

Here's a trimmed-down, app-agnostic version of what we shipped:

A few things worth pointing out:

Path parsing. [.c]req.path.split('/').filter(Boolean)[.c] gives you [.c]['invite', 'abc123'][.c] — the last segment is your ID. You can extend this for nested paths ([.c]/invite/group/abc123[.c]) or pull extra context from query params ([.c]?variant=team[.c]).

Optional query params. If the same invite link can resolve to slightly different previews (e.g. a different cover image depending on context), read [.c]req.query[.c] and fetch additional docs before building the meta. Fall back gracefully if the extra lookup fails.

HTML escaping. Database strings go straight into HTML attributes, so escape them — one stray [.c]"[.c] in a title and your OG tags are no longer valid.

Short cache. We use [.c]max-age=300[.c] so previews stay reasonably fresh if someone edits the invite, without querying Firestore on every WhatsApp re-fetch.

Absolute asset paths. The function serves HTML from [.c]/invite/abc123[.c], but Flutter assets live at the hosting root ([.c]/flutter_bootstrap.js[.c], [.c]/main.dart.js[.c], etc.). Relative paths like [.c]flutter_bootstrap.js[.c] would resolve to [.c]/invite/flutter_bootstrap.js[.c] — 404 city.

Two fixes, use both:

  • [.c]<base href="/" />[.c] in [.c]<head>[.c]
  • Absolute paths for scripts and icons: [.c]/flutter_bootstrap.js[.c], [.c]/icons/Icon-192.png[.c]

Browsers load the Flutter app correctly, and crawlers never cared about JS in the first place.

When this pattern fits

  • Flutter web (or any SPA) with shareable deep links
  • OG content lives in a database and changes over time
  • You already use Firebase Hosting + Cloud Functions

That's the whole trick — one rewrite rule, one Cloud Function, a handful of OG tags, and your invite links finally look as good in iMessage as they do in the app.

Farewell 👋

Category
Table of Content
Book a call now!
Alex
CTO at Krootl
Get a Consultation