Flutter web in 2026: WASM vs CanvasKit renderers, why canvas apps break SEO and link previews, and a decision checklist for when to actually ship Flutter web.
I've shipped Flutter to production on mobile for years, and every time a founder or a PM leans over and asks "can we just build the web version in Flutter too?" my answer starts with a long pause. That pause is the whole post. Flutter web is genuinely good now — the WASM renderer is real, the tooling is boring in the best way, and one codebase across mobile and web is a serious amount of leverage for a small team. But it is still the wrong tool for a whole class of products, and pretending otherwise is how teams end up rewriting their marketing site six months after launch.
So here's the honest version, from someone who has both shipped Flutter web and talked teams out of it. The core decision isn't "is Flutter web ready?" — it is ready. The decision is whether the thing you're building is an application or a document. Get that one distinction right and everything else falls into place. Everything below is me defending that single sentence from every angle a skeptical engineer or a growth-obsessed PM will throw at it.
For most of Flutter web's life you picked between two rendering paths, and the tradeoff was never subtle.
The HTML renderer built your UI out of DOM elements and CSS. Smaller download, plays nicer with the browser, but fidelity drifted — text rendering, clipping, shadows, and certain effects didn't match mobile, and complex scenes got slow. CanvasKit took the other road: it's a Skia build compiled to WebAssembly that paints your entire UI onto a single <canvas>. You get pixel-perfect parity with mobile because it is literally the same rendering engine your Android and iOS apps use. The cost is a WASM payload up front and — this is the part people underestimate — essentially no meaningful DOM. In 2026 the HTML renderer is effectively retired for app work; the CanvasKit/Skia path (you'll see it called "skwasm" in newer builds) is where Flutter's own guidance points you.
It helps to hold two separate ideas apart here, because people constantly conflate them:
They're orthogonal. You can run a CanvasKit UI on top of a JS-compiled app or a WASM-compiled app. Conflating "Flutter uses WASM to draw" with "my Dart runs as WASM" is where half the confusion in team discussions comes from.
dart compile wasm actually changedThe bigger shift, and the one everyone actually means when they say "Flutter runs on WASM now," is compiling your Dart app itself to WebAssembly with dart compile wasm, instead of transpiling to JavaScript via dart2js. This changes the runtime characteristics of your app, not just how it paints:
That last point deserves a warning I've learned the hard way: test the fallback path, don't assume it. The dual-compile output means you ship both the WASM and JS builds, and the browser picks at load time. Enabling WASM is one flag:
flutter build web --wasm
But "it built" is not "it works everywhere." Load the site in an older webview, in a corporate browser with strict flags, on a cheap Android device's Chrome, and actually watch which build loads and how it feels. I've seen a "done" PR that silently served the slow JS fallback to a chunk of real users because nobody checked which artifact the browser resolved to. The build succeeding tells you nothing about which path your users land on.
Here's the thing WASM did not fix, and it's the whole ballgame: whether you compile to JS or to WASM, a CanvasKit app still renders into a canvas. To a crawler, to a screen reader in the wrong mode, or to a plain "view source," there is essentially nothing there — a bootstrap script and a drawing surface. WASM made Flutter web faster and more consistent. It did not make it a document.
This is where I've watched projects go sideways, usually around month four when someone opens Google Search Console and the impressions graph is flat as a table.
Flutter web, in its default CanvasKit form, is a client-side-rendered (CSR) app that paints to a canvas. There is no server-side-rendered HTML, no per-route markup delivered on first byte, no semantic DOM tree for a crawler to index cheaply. Everything materializes only after the engine downloads, instantiates, and paints.
Now, modern search engines can execute JavaScript. Googlebot renders pages in a headless Chromium. So a Flutter site isn't literally invisible. But "can render it" and "will reliably index every route with correct metadata, quickly, on a finite crawl budget" are wildly different guarantees, and the gap between them is where organic traffic quietly dies. Here's what you actually give up:
<title>, meta description, canonical URL, and structured data (JSON-LD) present on the first byte. A canvas app has one shell; everything else is painted later, if the crawler decides to wait through the render queue.<head> tags and leave. A canvas app that injects those tags at runtime shows the scraper an empty shell, so your beautifully designed page pastes as a blank gray rectangle. In a product where sharing is the growth loop, that's not a bug, it's a strategy failure.Flutter does emit accessibility semantics into a DOM overlay, and that's genuinely useful — it's what makes screen readers work and gives a crawler something to chew on. But a semantics overlay built for a11y is not a substitute for server-rendered, indexable HTML with real per-route metadata. Don't let the presence of some DOM lull you into thinking SEO is handled. The overlay describes widgets for assistive tech; it does not deliver the title/description/canonical/structured-data package a search engine actually ranks on.
I don't lead with hacks, I lead with architecture. Roughly ordered by how well they survive contact with reality:
/app. Crawlers and scrapers get real HTML; your users get Flutter once they're inside. Two deploys, one clean seam.<head> metadata — title, description, Open Graph, JSON-LD — from the server or an edge function, so scrapers and link previews work even though the body is a canvas. This doesn't get you ranked, but it stops the blank-preview embarrassment when someone shares a deep link.sitemap.xml and real deep links. Every indexable route must be reachable by a stable URL and listed, so a crawler at least knows what exists and can decide to render it. If your routes only exist as in-app navigation state — no distinct URL, no back-button semantics — they don't exist to Google.The blunt rule I give every team: if organic search or link-sharing is a primary acquisition channel, that content does not go inside a Flutter canvas. No exceptions I've regretted.
/app split with nginx and base-hrefTalk is cheap, so here's the shape I actually deploy. The public site is Astro or Next, statically generated, sitting at the root. The Flutter build lives at /app. A thin edge or reverse-proxy config routes between them, and the SPA is only entered after real HTML has done its job. A minimal nginx seam:
# Public, crawlable, server-rendered site at the rootlocation / { root /var/www/site; # Astro/Next static output try_files $uri $uri/ /index.html;}# Flutter app lives under /app — pure SPA fallback, no SEO expectationslocation /app { alias /var/www/flutter; try_files $uri $uri/ /app/index.html; # long-cache the immutable engine artifacts location ~* \.(js|wasm|json)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }}Build the two independently and hand them to the same host:
# public site — this is what gets indexednpm --prefix ./site run build# the app — base-href matters or your deep links 404flutter build web --wasm --base-href /app/
That --base-href /app/ is not optional and it's the thing everyone forgets. Get it wrong and every asset path and deep link breaks in a way that looks like a mystery for an hour — the engine 404s on its own artifacts because it's resolving them against the wrong root. The seam itself is deliberately dumb: root serves static HTML, /app serves the SPA fallback, done. Dumb seams are the ones that don't page you at 2am. Every bit of cleverness you add to the routing layer is a future incident.
One more edge case worth stating out loud: make sure your reverse proxy and your Flutter router agree on trailing slashes and on what happens for an unknown /app/whatever path. The SPA fallback (try_files ... /app/index.html) is what lets client-side deep links survive a hard refresh — without it, reloading any in-app route returns a 404 from the server.
None of this means "don't use it." I reach for Flutter web often, because when the browser is a delivery mechanism for an app rather than a document to be indexed, the tradeoffs invert and it becomes a clear win:
The shared-codebase economics are the actual selling point, and they compound. When your Flutter mobile app and your web app share 70–80% of their Dart — the same repository, the same flutter_riverpod providers, the same freezed models, the same test suite — a small team moves at a pace that looks unfair from the outside. You write a validation rule once. You model a domain object once. You fix a race condition once. For a startup where engineering hours are the scarcest resource, that leverage is frequently the difference between shipping and stalling.
I won't pretend it's free. CanvasKit plus a WASM payload is a non-trivial first load — you're shipping a rendering engine before you paint a pixel, and on a cold cache over a weak connection that's felt. Budget for it deliberately:
immutable header above) so repeat visits skip the download entirely.And there's a second category of cost that isn't about bytes: native browser behaviors. Text selection, browser autofill, right-click context menus, print styles, form autocomplete, and in-page find (Ctrl/Cmd-F) all range from "needs work" to "actively fights you" on a canvas. If any of those are core to the experience — say you're building something people will print, or fill long forms in, or select-and-copy text from constantly — the canvas model is a headwind the whole way, and no flag makes it disappear.
Before committing a single sprint, I walk through these out loud with the team:
/app, someone has to own the routing, the base-href, and the preview metadata. Name that person before you commit, not after the first blank-preview bug report.If the answers point at "document," don't be clever. Reach for boring server-rendered HTML and be happy. Cleverness in the wrong place is just a slower rewrite with extra steps.
dart compile wasm runs your Dart. They're orthogonal — don't conflate "draws with WASM" and "my code is WASM."flutter build web --wasm improves p95 frame times and steady-state compute, but depends on WasmGC and silently falls back to the JS build. Test the fallback on real, constrained devices./app for the app behind the login. Remember --base-href /app/ and an SPA fallback.Flutter web in 2026 is a mature, genuinely good way to ship applications to the browser, and WASM made its performance story tighter and more predictable than it's ever been. What WASM did not do — what no build flag can do — is turn a canvas into a search-friendly, server-rendered, shareable document. That's an architectural fact, not a tooling gap.
So split the problem along that seam and stop fighting it: SSR/SSG for anything that must be crawled, previewed, or shared; Flutter for the app behind the login. Put the boundary in the right place, keep it dumb, and Flutter web stops being a gamble you defend in retros and starts being exactly what a small team wants — leverage.