Deep links & web URLs
Declare each URL once, in a NavLinks table — both directions at the same
time. Deep links, the address bar, browser back/forward, shareable links, and
state restoration all derive from this one table, so the two
directions can never drift apart. On the web, every history entry also
carries a full-stack snapshot, so browser back/forward restores the
entire typed stack — including in-app depth the URL can't express — not just
the URL's projection:
final links = NavLinks<AppKey>()
..on<Home>('/', decode: (m) => const Home())
..on<Product>('/products/:id',
decode: (m) => Product(m.integer('id')!),
encode: (key) => {'id': key.id},
parents: (key) => const [Home()]) // Back from a deep link goes Home
..on<Search>('/search',
decode: (m) => Search(m.str('q') ?? ''),
encode: (key) => {'q': key.q}) // not in the pattern → query param
..notFound((uri) => const [Home(), NotFoundScreen()]);
BackStackApp<AppKey>(stack: stack, entries: entries, links: links);:idmatches one path segment.decodereads it typed off theNavMatch:m.str,m.integer,m.number,m.boolean— all null-safe. A throwingdecodemeans "not mine after all" and the next pattern is tried, soProduct(m.integer('id')!)is the whole "numeric ids only" rule.parents:is the layer-vs-replace choice, per destination: the stack placed under the key when a link lands on it. Omit it and the link replaces the stack with just that screen.- Query parameters:
encodereturns one flat map — values named in the pattern fill its slots, every leftover entry becomes a query parameter.decodereads them with the samem.str('q'). *rest— a trailing catch-all matching zero or more segments, read viam.rest:..on<Doc>('/docs/*path', decode: (m) => Doc(m.rest.join('/'))).notFoundis your 404: malformed and unknown links land on that stack, never a crash.links.linkFor(const Product(42))returns the shareable URL for a key.
It's still not a route table that owns navigation — it only translates, and feature modules can each register their own entries into one shared instance.
Async resolution
Need to fetch before deciding what a link shows? Add onLinkAsync — return
the stack to show, or null to fall through to links. Race-safe: a newer
link supersedes an in-flight one, and errors fall through to the sync mapping.
BackStackApp<AppKey>(..., links: links,
onLinkAsync: (uri) async =>
await docExists(uri) ? null : const [Home(), NotFoundScreen()]);Links from native plugins
The platform hands back_stack the launch URL and the standard app links it
resolves itself. Custom-scheme links, Firebase Dynamic Links, and warm
app_links links come from a native plugin — pass them in and every Uri
flows through the same table:
final appLinks = AppLinks(); // create EARLY — see the ordering note below
BackStackApp<AppKey>(
// ...
initialLink: appLinks.getInitialLink(), // cold-start link (any plugin version)
linkStream: appLinks.uriLinkStream, // warm links while running
);Cold start: order, not luck. app_links' uriLinkStream replays the launch
URI to its first listener only if the AppLinks() singleton exists when the OS
delivers it. Create it early — a top-level final or in main() — not lazily in
a widget, or the first link is gone. To not depend on that at all, also pass
initialLink: appLinks.getInitialLink(); back_stack awaits it and applies the
launch link through the same mapping.
Full control
Prefer to write the mapping yourself? Pass onLink/toLink closures instead
of links — you decide what a link materializes ([Home(), Product(42)]
layers, [Product(42)] replaces), and a throw or empty result falls back to
onLinkFallback instead of crashing:
BackStackApp<AppKey>(
stack: stack,
entries: entries,
onLink: (uri) => switch (uri.pathSegments) {
['product', final id] => [const Home(), Product(int.parse(id))],
_ => [const Home()],
},
toLink: (stack) => switch (stack.last) {
Product(:final id) => Uri(path: '/product/$id'),
_ => Uri(path: '/'),
},
);NavPath keeps hand-written mappings terse — NavPath.build(['product', id])
builds encoded URLs, NavPath(uri).segInt(1) reads segments without
int.parse throwing. At the lowest level, a NavStackCodec (which NavLinks
implements) is the Uri ⇄ List<NavKey> seam for driving
NavStackRouterDelegate directly.
Drift is caught for you
In debug builds the router verifies encode∘decode is idempotent for every URL
it shows and reports a drifted codec the moment it's introduced — the classic
hand-written onLink/toLink bug, caught at development time. A NavLinks
table can't drift (one entry declares both directions), so this mainly guards
the full-control path.