DEV Community

Rakibul Hasan
Rakibul Hasan

Posted on AI-assisted

How I got a Flutter web app (CanvasKit) indexed by Google with static SEO shells

Flutter web is great for building an app once and shipping it everywhere. It's not great for SEO.

With the CanvasKit renderer, everything is drawn onto a <canvas>. If you open "view source" on a Flutter web page, all you get is a nearly empty index.html and a script tag. Google can render JavaScript, but rendering is queued and slow on a large app. Many other crawlers and the link preview bots (WhatsApp, Facebook, X) don't run it at all.

I ran into this building TypeorDrop, a free online file converter. It has 150 tool pages, like /convert-jpg-to-pdf or /merge-pdf. Every one of them needs its own title, description and indexable text, or it will never rank.

There are packages for this (flutter_prerender, esen_seo) and paid prerender services. I wanted the SEO text to come from the same database column the app already renders, with no new dependency in the Flutter app, so I wrote a small build step instead: static SEO shells.

The idea

A shell is a copy of Flutter's built index.html, generated at deploy time, one per route. Each copy gets:

  • a unique <title>, meta description and <link rel="canonical">
  • Open Graph and Twitter card tags
  • JSON-LD: SoftwareApplication, plus FAQPage when the tool has FAQ items
  • real body text: H1, intro, how-to steps, features, supported input and output formats, FAQ, related tools and related articles

The Flutter bootstrap stays the same. A crawler reads the HTML. A person's browser loads Flutter, which draws the app on top. The same URL serves the same HTML to everyone. Nothing switches on user agent, which would be cloaking.

build/web/
├── index.html                    ← homepage shell
├── convert-jpg-to-pdf/index.html ← shell for /convert-jpg-to-pdf
├── merge-pdf/index.html
├── ...
├── main.dart.js                  ← shared by every shell
└── sitemap.xml
Enter fullscreen mode Exit fullscreen mode

One source of truth for the text

The most important rule: the shell text should match what users see. If the hidden HTML says one thing and the app shows another, you're asking for trouble with search engines.

So the text doesn't live in the generator. Each tool row in the database has an seo JSON column (h1, intro, steps, features, FAQ, related tools). The API returns that column to the Flutter tool page, which renders the how-it-works, features, formats and FAQ sections from it. At deploy time, the same column is exported to a temporary JSON file and the generator reads it. When I edit a FAQ answer, the app and the shell both change on the next deploy.

The pricing shell and the homepage's popular-tools list go one step further: the generator reads them from the same Dart constants the app uses.

The generator (simplified)

The real script is longer and has tests (77 across the SEO and blog generators, all offline with mocked HTTP). This is the shape of it:

# runs after `flutter build web --release`
# tools = JSON export of active web tools, including the seo column
TEMPLATE = pathlib.Path("build/web/index.html").read_text()

for route, tool in tools.items():            # e.g. "convert-jpg-to-pdf"
    html = stamp_head(TEMPLATE, title, description, canonical)
    # replaces <title> + meta description; adds og:*, twitter:* and
    # <link rel="canonical"> just before </head>

    html = replace_seo_block(html, seo_block(tool))
    # fills the <div id="seo-content" class="visually-hidden"> placeholder
    # (outside the Flutter host element): h1, intro, how-to <ol>, features,
    # formats, FAQ, related tools/articles + SoftwareApplication & FAQPage JSON-LD

    html = replace_footer(html)              # plain-HTML footer with real links
    write(f"build/web/{route}/index.html", html)

# then: sitemap.xml (lastmod = each tool's updated_at), robots.txt,
# and the homepage shell (Organization, WebSite, ItemList of popular tools)
Enter fullscreen mode Exit fullscreen mode

A few rules live in the generator too: titles stay at 60 characters or less with the brand last, descriptions land between 120 and 160 characters, and duplicate titles or descriptions print a warning.

The part that makes it work: keep Flutter out of <body>

By default, Flutter takes over <body> and your shell content ends up under the canvas or gone. I use a custom flutter_bootstrap.js that mounts Flutter into a dedicated element:

_flutter.loader.load({
  onEntrypointLoaded: async (engineInitializer) => {
    const appRunner = await engineInitializer.initializeEngine({
      hostElement: document.querySelector('#flutter-host'),
    });
    await appRunner.runApp();
  },
});
Enter fullscreen mode Exit fullscreen mode

The #seo-content block sits outside #flutter-host, and nothing removes it after boot. It's hidden with a standard visually-hidden class, not display:none:

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
Enter fullscreen mode Exit fullscreen mode

Two fallbacks come almost free:

  • No JavaScript: a <noscript> style block turns the shell into a normal visible page.
  • Flutter fails to load: if the app hasn't painted after 20 seconds, the static page is shown instead of a blank screen.

Also make sure <base href="/"> is set. Otherwise /convert-jpg-to-pdf/index.html looks for main.dart.js inside its own folder and the app never boots.

Serving it with nginx

Canonical URLs have no trailing slash, so /convert-jpg-to-pdf must return the shell with a 200, not a redirect:

location / {
    # /convert-jpg-to-pdf -> convert-jpg-to-pdf/index.html, 200, no redirect
    try_files $uri $uri/index.html /index.html;
    add_header Cache-Control "no-cache";
}
Enter fullscreen mode Exit fullscreen mode

The detail that matters is $uri/index.html instead of $uri/. With $uri/, nginx adds its own trailing-slash redirect. I also 301 /x/ to /x, so the two rules together would loop. With $uri/index.html, there's no loop.

HTML is served no-cache, and each build ID is added to the URLs of main.dart.js, flutter_bootstrap.js and the service worker, so a deploy never leaves people on a stale app.

Things around the shells that helped

  • Sitemap from the same data. The generator writes sitemap.xml from the same tool list, and the blog and static page generators add their URLs. A tool can't be live and missing from the sitemap.
  • Clean removals. Every build starts from an empty folder, and the upload to the server deletes files that are no longer there, so a removed tool's page disappears. Routes I discontinued return 410 Gone, so search engines drop them fast.
  • IndexNow after deploy. When all post-deploy checks pass, the script POSTs every sitemap URL to api.indexnow.org. Bing and others pick up changes without waiting for a recrawl.
  • Noindex the API host. My API subdomain had started showing up in search results. A robots.txt on it plus X-Robots-Tag: noindex, nofollow fixed that. The tools endpoint stays allowed, because Googlebot needs it to render the real pages.
  • Plain HTML for articles. Blog posts are fully static pages generated from Markdown. Flutter is the wrong tool for article pages. The deploy script fails if a post accidentally includes the Flutter bootstrap.

What I'd still improve

  • Soft 404s. Unknown URLs fall back to /index.html with a 200, so a mistyped URL gets the homepage instead of a real 404. Blog URLs already return real 404s.
  • og:image on tool pages. Link previews show the right title and description, but tool shells don't have their own preview image yet.

Results so far

Before shells, every URL returned the same empty document with the same title, and Search Console showed little beyond the homepage. Now tool pages get indexed on their own, each with its own title and description in results, and chat link previews show the right page.

The site is young, so rankings are moving slowly. But the "Google can't see my Flutter app" problem is solved, and it needed no SSR, no second frontend and no new Flutter package.

When this approach is enough

Static shells work well when your public pages are known at build time: tool pages, landing pages, docs. If you have millions of user-generated pages, you need real server rendering or a prerender service.

If you're shipping a Flutter web app and wondering why nobody finds it, open "view source" on a few of your URLs. If they all look the same, that's your problem.


I build TypeorDrop: free file conversion, PDF and image tools in the browser, built with Flutter web, FastAPI and Celery. Questions about the setup are welcome in the comments.

Top comments (2)

Collapse
 
lycore profile image
Lycore Development •

Keeping the shell text generated from the same database column the app renders is the part that makes this safe, since it avoids drift between what crawlers and users see. One thing we'd check is that the visually-hidden block stays genuinely equivalent to the visible Flutter UI, because Google can treat hidden-only text as a risk if it diverges. It's also worth confirming the shells serve a proper 404 for unknown routes rather than falling back to index.html, otherwise soft-404s can pile up in Search Console. Did you see a difference in indexing speed after adding the JSON-LD for FAQPage?

Collapse
 
lycore profile image
Lycore Development •

Keeping the shell text generated from the same database column the app renders is the part that makes this safe, since it avoids drift between what crawlers and users see. One thing we'd check is that the visually-hidden block stays genuinely equivalent to the visible Flutter UI, because hidden-only text can be a risk if it diverges. It's also worth confirming unknown routes return a proper 404 rather than falling back to index.html, otherwise soft-404s can pile up in Search Console. Did you notice a difference in indexing speed after adding FAQPage JSON-LD?