Flutter web paints to a canvas, so crawlers get an empty DOM. The crawlable article body, JSON LD @graph, generated OG cards and honest sitemap that fixed it.
Open a Flutter web build in a browser, hit View Source, and the <body> you get back is a loading <div> and a <script src="flutter_bootstrap.js">. That is the whole page. Every heading, paragraph and link a human reads is pixels that CanvasKit painted into a <canvas> after a couple of megabytes of WebAssembly finished downloading. There is no DOM text. There is nothing for a crawler to index and nothing for a link unfurler to quote.
My portfolio is exactly that stack — one Flutter web app on Firebase Hosting serving 98 free browser tools, 32 browser games and 114 blog posts, all client-side, for $0 a month. Single codebase, single deploy, and the tools genuinely run in your browser rather than on my bill. I would make the same call again. But it means SEO on Flutter web is not a package you add to pubspec.yaml — it is a set of things you put back into the DOM by hand, for two audiences that behave completely differently: crawlers that execute JavaScript, and crawlers that absolutely do not.
This post is everything I actually shipped to make that site indexable and shareable: a hidden but crawlable <article> injected alongside the canvas, per-page JSON-LD emitted as a @graph, an og:type vocabulary of exactly two values, canonical URLs that survive client-side routing, 122 deterministic 1200×630 share cards generated with Pillow and served from Hosting instead of Storage, an RSS 2.0 feed with auto-discovery, and a 251-URL sitemap whose lastmod tells the truth. Plus the operational half nobody writes down: which of this needs a rebuild and a deploy, and which goes live the second you press Publish.
Flutter's web renderer draws into a canvas. That is not an implementation detail you can shrug off — it is the entire SEO problem in one sentence. The semantics tree Flutter can emit is an accessibility overlay of ARIA nodes, not article text, it is off unless something requests it, and it is not what a search engine reads for content. As far as the DOM is concerned, your 3,000-word post does not exist.
Googlebot does render JavaScript, so in principle it can wait for the canvas and read whatever the app puts in the DOM afterwards. In practice you are asking a crawler to download a large WASM payload, boot a Dart runtime, wait for a Firestore round trip, and only then look at your page — on a rendering queue you do not control, with a budget you do not get to see. Anything you can hand it in the first response instead is a strictly better deal.
And Googlebot is the forgiving case. The fetchers behind Slack, LinkedIn, X, WhatsApp, Facebook and Discord previews do not run JavaScript at all. They issue one plain HTTP GET, read the <head>, and leave. That single fact dictates the architecture of everything below. It is why per-page Open Graph tags injected at runtime are half a solution, why the share card URL has to be absolute and static, and why the sitemap and the RSS feed are flat files in web/ rather than something computed at request time. There is no server-side rendering here and — on a Blaze project I am determined to keep at $0 — no Cloud Function pre-renderer either. Everything has to work as static files plus runtime DOM mutation.
<article>The first thing I built is the least clever and by far the most valuable: when a blog post loads, the app renders the post's Markdown to HTML and injects it into the DOM as a real <article> element, visually hidden but fully present.
el.setAttribute('style', 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' 'overflow:hidden;clip:rect(0,0,0,0);white-space:normal;border:0;');web.document.body?.appendChild(el);That is the standard visually-hidden recipe, and the choice of recipe matters. display:none and visibility:hidden are the two things you must not use — crawlers have discounted content in those containers for years, precisely because they were the classic hiding trick. A 1×1 clipped absolute box is the same pattern every accessible site uses for skip links and screen-reader labels: the content is in the accessibility tree, it is in the DOM, it is simply off-screen.
The body itself comes from the same Markdown the canvas renders, converted once with the markdown package:
articleBodyHtml: md.markdownToHtml( post.content, extensionSet: md.ExtensionSet.gitHubWeb,),
Two details that took me a second pass to get right. First, the injected copy has to be a faithful mirror of what the user sees, not an enriched keyword version. The moment the hidden text diverges from the rendered text you are cloaking, and cloaking is a manual-action-level offence, not a ranking tweak. Because both come from the exact same post.content string, mine cannot drift.
Second, escape everything you interpolate around it. The wrapper adds an <h1>, the description and a self-link, and all three go through HtmlEscape before they touch innerHTML. The Markdown body is already HTML by then; the fields around it are raw Firestore strings, and a post title containing an ampersand or an angle bracket will happily produce broken markup or worse.
One deliberate asymmetry: only pages of type article get a body injection. A tool page's "content" is an interactive widget — there is no prose to mirror, and inventing some would be exactly the cloaking problem again. Tools and games get rich <head> metadata and structured data instead, and their real indexable text lives in the title, description and JSON-LD.
A single-page app changes routes without a document load, so every tag you set is stateful. This is the failure mode people hit and never notice: navigate from a blog post to a tool page, and the tool page inherits the post's article:published_time, its article:tag entries, and a stale canonical. Now you are serving a tool page that claims to be an article published last Tuesday.
So the SEO helper is written around one rule: every update is a full replacement, including removals. _setMeta finds-or-creates a single element per key and overwrites its content. _setCanonical reuses one <link rel="canonical">. JSON-LD lives in exactly one <script id="seo-jsonld"> whose textContent is replaced wholesale. And repeatable tags — article:tag is the only one — get stamped with a data-seo-multi attribute on creation so they can all be swept before the new set is written:
static void _removeMetaAll(String keyValue) { final list = web.document.querySelectorAll('meta[data-seo-multi="$keyValue"]'); for (var i = 0; i < list.length; i++) { (list.item(i) as web.Element?)?.remove(); }}The non-article branch explicitly removes article:author, article:section, article:published_time, article:modified_time and every article:tag. It is boring code. It is also the difference between metadata that describes the current page and metadata that describes whatever the user looked at three clicks ago.
Canonicals get the same treatment. Every page computes its own absolute URL from a single siteUrl constant plus its route path, and writes it to the canonical link and to og:url from the same variable, so the two can never disagree. Relative canonicals are legal but pointless here — the unfurlers need absolute anyway.
og:type has a tiny vocabulary, and invalid values fail silentlyInternally my pages have four kinds: website, article, tool, game. Open Graph has no idea what a "tool" is. Its type vocabulary is small and closed, and a parser that meets og:type="tool" does not warn you — it ignores the value and falls back, which means you get a generic unfurl and never find out why.
The fix is one line, and it is the sort of line worth a comment:
// og:type only accepts a small vocabulary — anything non-article is a// website as far as Open Graph is concerned.final ogType = isArticle ? 'article' : 'website';
The richer distinction between a tool and a game does not disappear — it just moves to the layer that can actually express it, which is schema.org.
@graph, one entity type per page kindStructured data is where a canvas app can claw back most of what it lost. The crawler may not be able to read my rendered page, but it can read a JSON blob in the head perfectly, and that blob can say precisely what the page is.
Each page emits a @graph with two nodes: the page's main entity, and a BreadcrumbList for the trail that led to it. Three main entity types cover the whole site:
SoftwareApplication for the 98 tools — applicationCategory: UtilitiesApplication, operatingSystem: "Any (web browser)", a browserRequirements string that says no install, and isAccessibleForFree: true.VideoGame for the 32 games — gamePlatform: "Web Browser", playMode: SinglePlayer, and the game's category as genre.BlogPosting for the posts — headline, datePublished, dateModified, articleSection, keywords, author, publisher and mainEntityOfPage.Every tool and game node carries a zero-price Offer:
{ "@type": "SoftwareApplication", "name": "Base64 Encoder / Decoder", "url": "https://devshakibio.web.app/tools/base64", "applicationCategory": "UtilitiesApplication", "operatingSystem": "Any (web browser)", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "isAccessibleForFree": true}That Offer is not decoration. A SoftwareApplication without an offer is an app whose price is unknown; one with price: "0" is an app that is free, and those are different facts to a machine. isAccessibleForFree says the same thing in a second vocabulary, which costs nothing and covers more consumers.
Two rules I encoded rather than remembered. headline is truncated to 110 characters, because that is the documented limit and a longer headline invalidates the whole node — my titles are keyword-rich and routinely run past it. And wordCount is derived from the post's stored readingMinutes × 200 rather than counted at render time, so it is consistent with the reading estimate shown on the page instead of quietly contradicting it.
The BreadcrumbList is the cheapest win in the set. Three ListItem entries — Home, section, page — turn a bare URL in the results into a readable path. It takes about fifteen lines to generate from a list of (name, url) pairs and it applies to every page on the site.
Before this, every link to my site unfurled with the same generic og-image.png. Every tool, every game, every post — one image. It is the visual equivalent of a shrug.
The fix was a Pillow script that renders a 1200×630 PNG per entity: the tool or game name in large type, the category as an eyebrow, the devShakib wordmark, and a gradient panel. The important property is that it is deterministic — the palette is chosen by hashing the slug into a fixed colour list, so tools/base64 produces the identical card on every run. Regenerating is idempotent, diffs stay clean, and nothing silently reshuffles when I add a tool in the middle of the list.
def palette_for(slug: str) -> tuple: idx = int(hashlib.sha1(slug.encode()).hexdigest(), 16) % len(PALETTES) return PALETTES[idx]
1200×630 is the size to target: it satisfies Open Graph's recommended 1.91:1 and it is comfortably above the 300×157 floor below which X and Facebook drop to a small thumbnail. Paired with twitter:card = summary_large_image, that is the big banner unfurl rather than the little square.
The decision I want to underline is where those 122 files live. They sit in web/og/tools/<slug>.png and web/og/games/<slug>.png, which Flutter copies into build/web and Hosting serves as static assets. They are not in Cloud Storage, even though every other image on the site is. A Storage download URL looks like this:
https://firebasestorage.googleapis.com/v0/b/<bucket>/o/public%2Fog%2Fbase64.png?alt=media&token=6f2c...
Percent-encoded path, a query string, a UUID token that can be rotated and break every card at once, and a third-party origin in a tag that some consumers treat conservatively. The Hosting version is https://devshakibio.web.app/og/tools/base64.png. Same origin as the canonical URL, no token, trivially predictable from the slug, and it picks up the Hosting cache header I already set for images:
{ "source": "**/*.@(png|jpg|jpeg|gif|webp|svg|ico)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=2592000" }] }The tool scaffold then builds the URL by convention rather than storing it — '${AppConfig.siteUrl}/og/tools/$slug.png' — which means adding a tool means adding one PNG, not editing a mapping table.
lastmod that tells the truthweb/sitemap.xml currently carries 251 URLs: the six top-level routes, 98 tools, 32 games, the blog index and every published post. Priorities are tiered — 1.0 for home, 0.9 for the section indexes, 0.6 for leaf pages — and changefreq reflects reality rather than optimism.
The one field I care about is lastmod, and there is exactly one rule: lastmod tracks updatedAt, never publishedAt. It is tempting to bump every date on every generation so the whole site looks fresh. Do that and you have taught the crawler that your lastmod carries no information, and it will start ignoring the field — including on the day you actually fix a broken post and genuinely want a recrawl. Google has been explicit that inconsistent lastmod values get discounted. A lastmod you have lied on is worse than no lastmod at all, because you have spent trust you cannot easily earn back.
The same discipline runs through web/feed.xml, a plain RSS 2.0 document with an atom:link rel="self", dc:creator, per-item category and pubDate, and guid isPermaLink="true" set to the post URL so readers de-duplicate correctly. It is discoverable because index.html declares it:
<link rel="alternate" type="application/rss+xml" title="devShakib — Blog" href="/feed.xml">
RSS is not a traffic strategy in 2026. It is a fifteen-line file that makes a certain kind of reader — the kind who reads engineering blogs — able to follow you without an algorithm in between. That is a good trade.
This is the split I wish someone had drawn for me on day one, because it changes how you plan a publish.
Code-level SEO needs flutter build web and firebase deploy --only hosting. Everything in lib/core/seo.dart — the tag writers, the og:type mapping, the JSON-LD builders, the hidden-article injector — compiles into the Dart bundle. Change the shape of your BlogPosting node and nothing moves until you rebuild and ship. The same goes for everything in web/: index.html, robots.txt, sitemap.xml, feed.xml and all 122 share cards are static files copied into build/web at build time. They are not read from a database at runtime.
Content-level SEO is live the moment Firestore accepts the write. Title, excerpt, tags, category, cover image, dates and the Markdown body all come from the posts document. Publish from the admin panel and the next visitor gets a fully-tagged page: fresh <title>, fresh description, fresh article:tag set, fresh JSON-LD with the right dates, and a hidden <article> containing the new body. No build, no deploy, no cache to bust.
The gap between those two is real and worth stating plainly: a new post is crawlable immediately but is absent from sitemap.xml and feed.xml until the next deploy regenerates them. Discovery lags publication. On a site that ships two posts a week that is fine — internal links from the blog index do most of the discovery work anyway, and the sitemap is a hint rather than a requirement. The honest alternative is a Cloud Function regenerating both files on write, and that costs money I have decided this site will not spend.
I am living the same lag right now on the visual side. There are 122 cards for 98 tools and 24 games, but the site has 32 games — the eight newest, the cards and casino set, are still unfurling with the site-wide default until the next generation run. That is the tradeoff in miniature: the Firestore-driven half of a new game was live in seconds; the static half waits for a build. Knowing which half you are touching is most of the operational skill here.
<article> injected from the same Markdown the canvas renders, never display:none and never text that differs from what the user sees.og:type accepts a tiny closed vocabulary and ignores anything else without complaining — collapse your internal types to article or website, and express the real distinction in schema.org instead.@graph with the page's entity plus a BreadcrumbList, and give free software a zero-price Offer — "price unknown" and "price is zero" are different facts to a machine./og/tools/<slug>.png beats a tokenised download URL in every consumer.lastmod — bumping every date each generation teaches crawlers that your freshness signal is noise, and you will want that signal on the day something actually changes.None of this is clever. It is a hidden <article>, a @graph, two valid og:type values, a folder of PNGs and two flat XML files — put in place once, in a helper every public page calls when its data loads. The canvas is still a canvas; the crawler just no longer has to care.