Sitemap

We Open-Sourced Our Flutter Navigation Package After 8 Months in Production

6 min readJul 22, 2026

--

Press enter or click to view image in full size

Most open-source navigation packages start life as a greenfield experiment — a README, a demo app, and a hope that real users will find the edge cases. Ours didn’t.

j_navigation was extracted from Jota, a shipping fintech app. Tap-to-pay, PIX payments, in-app chat, open finance — real users, real crash reports, real navigation edge cases that only surface when real traffic hits your router. It ran in production for eight months, across ninety commits and three engineers, before we pulled it out and open-sourced it.

This is the story of why we built our own, what makes it different, and the honest case for why you might (or might not) use it.

Why not just use go_router?

Let’s get this out of the way: go_router is not dying. It’s first-party, Flutter-team-maintained, on v17, pulling ~3.5M downloads a month, and actively shipping bug fixes. We checked. If you’re happy with go_router, keep using it.

We weren’t unhappy with go_router exactly — we’d never picked it. Our app predates the extraction; the navigation controller grew organically inside Jota, shaped by the architecture we’d already committed to: MVVM + Signals + Coordinator. In that architecture, ViewModels never touch a BuildContext. Navigation lives in coordinators. State lives in signals.

Every mainstream Flutter router is built around the opposite assumption: you navigate from a widget, via context.go() or context.pushRoute(). That’s fine for a lot of apps. It fights ours. We didn’t want to bolt a context-free escape hatch onto a context-first API. We wanted navigation to be a context-free controller API, from the ground up.

And the shape we built has a name. Omni navigation — the ability to trigger navigation from anywhere in your app, including business-logic layers like view models, without needing a BuildContext. It’s a known idea in Flutter; the usual recipe is a global navigatorKey plus a router you grab through DI. We wanted it by construction, not as an escape hatch bolted onto a context-first API. And as we built it, a second property emerged that we hadn’t planned: navigation to anywhere, too — not just from anywhere. No route registry gating the set of destinations. The two together — from anywhere, to anywhere — are the full shape of what we mean by omni navigation.

So we built it.

What makes it different

Six things, all verified against the actual code before we wrote any of them down.

1. The controller has no `BuildContext`

// anywhere — a coordinator, a view model
controller.navigate(
Push(analyticsIdentifiable: AnalyticsIdentifiable(
screenName: 'Charge',
builder: (context) => ChargeScreen(amount: amount),
)),
);

No context.go(). No routerConfig needed at the call site. The controller IS the API. This is the thing that makes it fit a coordinator architecture cleanly — and makes navigation unit-testable without ever pumping a widget tree. This is the from anywhere half of omni navigation — and unlike a global navigatorKey workaround, it’s the API’s default, not a bolt-on.

Is this unique to us? No — you can grab a GoRouter through DI and call it context-free-ish too. The distinction is narrow but real: j_navigation is context-free by construction, not as an opt-in escape hatch bolted onto a context-first API. It starts where the incumbents arrive only after configuration.

2. Navigation is an immutable action object

Every navigation is a value object — Push, Present, Dismiss, PopTo, ReplaceStack, SwitchTab. Each one is a pure stack transformation: navigationStackFrom(currentStack) → newStack. That makes navigation deterministic, testable, and replayable as data.

// this is data, not a side effect
final action = Push(...);
controller.navigate(action);

3. No codegen, no route registry

go_router wants a GoRoute tree declared upfront. auto_route wants @RoutePage() annotations plus a build_runner run. Adding a screen means editing the router config or regenerating.

Get Danilo Becke’s stories in your inbox

Join Medium for free to get updates from this writer.

j_navigation has no registry. The builder IS the route. Navigate to any screen with a WidgetBuilder, ad hoc, from anywhere:

controller.navigate(Push(analyticsIdentifiable: AnalyticsIdentifiable(
screenName: 'Anything',
builder: (context) => AnyScreen(), // no registration, no path, no codegen
)));

This is the to anywhere half of omni navigation. A router with a global key lets you navigate from anywhere — but the set of places you can reach is still whatever the registry declared. j_navigation drops the registry: any builder, anywhere, is a valid destination, with no central file to edit, no codegen to run, no registry to keep in sync with your feature code. A new screen is just a Push away. From anywhere, to anywhere — the full shape.

The honest tradeoff: no registry means no automatic URL ↔ route mapping. Deep-linking is opt-in — you register only the screens you want deep-linkable through a FeatureProvider. We optimized for navigation-first; deep-linking is a deliberate opt-in, not a free side effect of declaring routes.

4. Tabs are first-class

Jota runs a bottom-nav shell. We generalized that into the package: NavigationController.tabbed(…) holds a branch per tab, each branch is its own stack, and SwitchTab is a pure pointer flip between them.

final controller = NavigationController.tabbed(
branches: [
NavigationBranch(id: Tab.home, initialNavigation: ..., wantsKeepAlive: true),
NavigationBranch(id: Tab.profile, initialNavigation: ...),
],
shellBuilder: (context, child) => MyBottomNavShell(child: child),
);

5. Analytics built in, behind a swappable sink

Screen-enter and screen-exit events fire from the controller — not from a NavigatorObserver, so they’re correct per-branch even in tabbed mode. They go through a NavigationAnalyticsSink interface you implement. Each event carries more than a screen name: the navigation method that got you there (push, present, switch_tab, …), the time spent on the screen before you left it, and a breadcrumb trail of the stack at enter time — so you can reconstruct not just what screen was viewed, but how the user arrived and how long they stayed.

6. Production-proven (the engine, not the shell)

The engine has run in Jota for eight months. The navigation controller carries zero crash attribution in our crash tracker across that window. The hard things — keyboard-deferred navigation, bottom-sheet dedup, Android system-back exit semantics, deep-link cold-start races — were hit in production and fixed with commits, each one solving a real edge case.

Honest caveat: the tabbed shell and the awaitable-present API are new in 1.0.0 — they’re test-covered but not yet adopted in Jota. “Production-proven” is true for the engine. It’s not yet true for the shell. We say so in the docs because we’d rather you trust us.

The one thing we didn’t build

State restoration on process death. The universal dirty secret of Flutter nav: both go_router and auto_route are broken here too for per-branch back stacks. Restoring “tab B, three screens deep” after the OS kills your app is genuinely hard — it means serializing a stack of navigation actions and replaying them, resolving deep-link-vs-restored-state conflicts, racing cold-start deep links against restored state.

We punted on it for 1.0.0. It’s the one place where owning the hard problem would be a real switch-reason from the incumbents, and we chose to be honest about not owning it yet rather than ship a half-implementation. It’s on the roadmap. If you need it today, nobody has it solved — and you should know that’s the bar.

Should you use it?

If you’re starting a fresh app tomorrow and go_router feels natural to you: use go_router. It’s excellent and well-maintained.

If your architecture fights context-first navigation — a coordinator pattern, ViewModels that can’t hold a BuildContext, a team allergic to codegen — j_navigation was built for exactly that. And if omni navigation — navigation from anywhere, to anywhere, with no BuildContext and no route registry gating the set — is the shape you want your routing to take, this is the package shaped around it.

It’s niche, intentionally. We’re not trying to displace the default; we’re giving the people whose architecture doesn’t fit the default a package built for theirs.

It’s MIT-licensed, 95 tests, zero analyzer issues, and it runs the navigation of Jota today.

Repo: https://github.com/usejota/j_navigation
Jota App: https://app.jota.ai
Website: https://jota.ai

dependencies:
j_navigation: ^1.0.1

Born in production. Extracted, not invented. We hope it’s useful.

If you hit an edge case, find a gap, or have a navigation horror story of your own — open an issue. The hard cases are how this package got good in the first place.

--

--

Danilo Becke
Danilo Becke

Written by Danilo Becke

Mobile engineer at Jota, passionate about education and exponential technologies