Skip to content
Blog

Migrating Flutter Web from Hash to Path URLs Without Breaking a Bookmark

A safe hash-to-path migration for Flutter web: why the server can't redirect fragments, the boot-time replaceState rewrite, canonical tags, and a rollback plan.

Published on • November 13, 2026

AI Assistant

flutter-url-strategies-web helps you choose between hash and path URLs, and flutter-web-hosting-rewrites-url-strategy shows how to serve path URLs. This post is for the team that already shipped with the hash default and now wants clean URLs — without a single bookmark, shared link, or indexed page turning into a 404. The migration has one counter-intuitive core: the fragment never reaches your server, so almost all of the work happens in the client’s boot sequence.

Why the migration is safe — and why it isn’t

Old links look like https://app.example.com/#/orders/42. The browser requests only https://app.example.com/ and handles #/orders/42 locally. That has two consequences:

  1. Your server never sees the fragment, so a server-side 301 from /#/orders/42 to /orders/42 is impossible — there is nothing in the HTTP request to redirect.
  2. The old links keep loading (they hit the root, which your SPA rewrite already serves), they just resolve to the wrong route once the new strategy reads location.pathname instead of location.hash.

So the migration is a client-side rewrite of history, plus ordinary server work for the new URLs.

Step 1: switch the strategy and keep both readers during the transition

import 'package:flutter_web_plugins/url_strategy.dart';

void migrateLegacyHashUrls() {
  final loc = Uri.base;
  final hash = loc.fragment;                 // e.g. "/orders/42"
  if (hash.startsWith('/')) {
    // Old link: /#/orders/42 → replace in place, no extra history entry.
    history.replaceState(null, '', Uri(path: hash, query: loc.query).toString());
  }
}

void main() {
  usePathUrlStrategy();
  migrateLegacyHashUrls();
  runApp(const MyApp());
}

Run this before runApp so the router never observes the stale hash URL. replaceState (not pushState) means the user’s back button still behaves. Because usePathUrlStrategy() reads location.pathname at startup, the rewrite must land first — the ordering in main() is the whole trick.

Edge cases the shim must survive:

  • https://app.example.com/#/orders/42?tab=open — the fragment can carry a query string; parse it and rebuild both path and query (the snippet above does this via loc.query).
  • https://app.example.com/#section-3 — an anchor fragment (no leading /) is a page anchor, not a route: leave it alone, or you’ll turn a scroll target into a bogus route.
  • https://app.example.com/# or #​/ — a bare hash: normalize to / so the router doesn’t see an empty path segment.

Step 2: canonical and social tags now point at path URLs

Once path URLs are live, every page’s metadata should reflect them — in the same deploy as the strategy switch:

<link rel="canonical" href="https://app.example.com/orders/42" />
<meta property="og:url" content="https://app.example.com/orders/42" />
<meta name="twitter:url" content="https://app.example.com/orders/42" />

Hash URLs were effectively invisible to crawlers as distinct pages; path URLs get indexed, which is usually the motivation in the first place. If you server-render meta per route (or generate static entry points), update those generators in the same release — a deploy where URLs changed but canonicals didn’t is worse than not migrating at all. Add the new URLs to your sitemap the same day, and keep the sitemap entries fragment-free.

Step 3: treat it as a release, not a config edit

The migration checklist:

  1. Server rewrites first. Deploy the index.html fallback for unknown paths before flipping the client strategy — in that order, an old app version keeps working (hash URLs never needed rewrites) and the new one works immediately.
  2. Test the matrix: old hash link → lands on correct route; new path link → survives hard refresh; shared link from a fresh browser → correct route; deep link with query string → query preserved.
  3. Platform links: if you registered App Links (Android) or Universal Links (iOS) against path URLs, they only start matching after this switch — verify with flutter run link validation or the OS’s verification tools.
  4. Keep a rollback: reverting is just removing usePathUrlStrategy() and the migration function — but only until search engines have crawled the new URLs. After that, prefer adding redirects over going back.

The migration test matrix

Run this matrix before and after the switch — each row is a bug a team has actually shipped:

#ScenarioExpected after migration
1Old bookmark /#/orders/42 opened freshLands on /orders/42, history shows path URL
2New /orders/42 hard-refreshed (Ctrl+Shift+R)App restores on the same route, no 404
3/orders/42?tab=open shared in chatRoute and query survive the round trip
4Deep link with no prior session (cold incognito)Same as 2 — rewrites serve index.html
5Back button after the shim rewrote a hash URLNo extra history entry; back exits the app normally
6Anchor link /#section-3 (if you use them)Scroll anchor untouched, not treated as a route
7curl -I /orders/42200 text/html, not 404
8curl -I /main.dart.jsReal JS bundle, not the HTML shell

Rows 1, 5, and 6 exercise the migration shim; rows 2, 4, 7, and 8 exercise the server rewrites; row 3 catches the query-string handling that most shims forget.

When to stay on hash

The hash strategy remains legitimate when you control no server at all — a static host without rewrites, an about:blank-adjacent embedding, or an intranet share drive. In those cases the fragment is the routing mechanism, and no migration exists to do. The cost is aesthetic and SEO-related, not functional: everything works, the URLs are just ugly and unindexable.

One more edge: users who still have an old /#/… bookmarked after you migrate will keep hitting the root with a hash — which is exactly what migrateLegacyHashUrls() above converts. Ship that function in the first release after the switch, and leave it there for months, not days; fragments live forever in bookmarks and chat histories.

Takeaway: Rewrite legacy hashes client-side with history.replaceState before runApp, deploy server rewrites before the strategy flip, update canonical/OG/sitemap URLs in the same release, and keep the migration shim long after launch — the server will never see those old fragments to redirect them for you.

Sources