The post has been translated automatically. Original language: Russian
Navigation in Flutter almost always starts innocuously.
First there are two screens:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const DetailsPage(),
),
);Then authorization appears. Then bottom navigation. Then the deeplink from the push notification. Then the web version. Then you need to open the screen on top of the current tab, but save the old one. Then the user presses the system back button, and suddenly the application does not behave as expected.
It is at this point that it becomes clear: navigation does not break when there are a lot of screens in the application. It breaks down much earlier — when the team doesn't have a clear model of what a route is, who owns the stack, and how the URL, application state, and UI relate to each other.
The main idea
Navigation is not just about switching between screens.
In a normal app, navigation is responsible for several things at once.:
- which screen is open;
- which stack is behind it;
- is it possible to access this screen by following the link;
- what happens when you back up;
- how authorization works;
- is the state of the tabs saved?;
- how does an application recover after killing a process;
- what the user sees on the web;
- what to do with push/deeplink;
- where modal screens should live.
If all this is based on disparate Navigator.push(), sooner or later navigation turns into a hidden architecture that no one explicitly designed.
The first stage: imperative navigation is fine as long as the application is small
Navigator.push() and Navigator.pop() are great tools for simple scenarios.
For example:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProductPage(productId: product.id),
),
);This code is clear, local, and does not require unnecessary infrastructure.
The problem begins when this style becomes the only navigation model in the entire application.
For example:
if (user == null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginPage()),
);
} else if (!user.isOnboarded) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const OnboardingPage()),
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomePage()),
);
}At first glance, everything is still fine. But an important problem is already hidden here: navigation has become a consequence of the state of the application, but it is described as a set of commands.
And the commands don't answer the question well: which screen should be shown in this state?
They only answer the question: what action should I take now?
These are different things.
The second stage: named routes give order, but do not solve the architecture
The next step is usually like this:
Navigator.of(context).pushNamed('/product');Named routes make the code more accurate. You don't need to create a MaterialPageRoute everywhere, you can keep a route map in one place.
But this approach has limitations. Most importantly, named routes do not scale well for scenarios where the URL, deeplink, web browser history, and application status must synchronously determine the stack of screens.
For example, if the application has received a deeplink /product/42/reviews, it is not enough to simply push the screen. We need to understand:
- is the user logged in or not;
- is he allowed to see this product;
- do I need to open the tab Catalog;
- should reviews include product details?;
- what will happen with the back;
- how this URL should look on the web;
- do I need to restore the old tab stack.
Named routes give a name to the transition. However, they do not always provide a complete model of the navigation state.
Stage three: The deeplink suddenly shows that the stack was implicit
A deeplink is a moment of truth.
As the user walks through the application with their hands, the stack is built gradually:
- Home
- Catalog
- Product
- Reviews
But the deeplink wants to open right away:
/product/42/reviewsAnd then the question arises: what should be in the navigation stack?
ReviewsPage only? ProductPage + ReviewsPage? HomePage + CatalogPage + ProductPage + ReviewsPage? Or even a separate modal branch?
If you don't have an answer, the deeplink will work randomly.
That's why a deeplink is not to add a link handler. This checks whether the application is able to build a navigation status from an external address.
Fourth stage: bottom navigation breaks the only stack
Bottom navigation often looks simple:
BottomNavigationBar(
currentIndex: index,
onTap: (value) {
setState(() {
index = value;
});
},
)This is enough to switch tabs easily.
But the expectation quickly arises in the product: each tab should have its own stack.
For example:
- the user opened the product in the Catalog tab;
- then he went to the Profile tab;
- returned to Catalog;
- expects to see the same product, not the initial catalog screen.
If the application has one common Navigator, compromises begin. Either the tabs lose their state, or the back starts behaving strangely, or the code gets overgrown with conditions.
The correct model here is usually this: each main tab has its own nested navigator or declarative route branch, which retains its stack.
And it needs to be designed in advance. Otherwise, bottom navigation turns into a source of the most annoying bugs: the wrong thing closes back, the tab is reset, the screen opens on top of the wrong branch.
Fifth stage: auth guard is not a switch to login button
Authorization is another frequent source of chaos.
A bad model:
if (!isLoggedIn) {
Navigator.of(context).pushNamed('/login');
}Why is it bad? Because authorization is not a local action of a single screen. This is a global access condition for a part of the route tree.
It's more correct to think so:
- If the user is not logged in, private routes are not available.;
- if the user is logged in, login/register should no longer be a regular entry point.;
- after login, you need to return to where the user wanted to go.;
- after logout, you need to reset the private stack;
- The deeplink to the private screen must go through auth flow.
In other words, auth guard is not a push('/login'). This is the rule for converting the navigation state.
The sixth stage: The web makes navigation a public contract
On mobile, an app can live for a long time with internal routes that only the code sees.
On the web, the URL becomes part of the product.
The user expects that:
- the page can be updated;
- The link can be copied;
- browser back/forward is working;
- The URL corresponds to the open screen;
- the path without # works with the correct server configuration.;
- The deeplink does not break the current application structure.
If an application has grown out of a set of Navigator.push(), the web often opens everything at once: the URL does not match the screen, refresh opens the wrong thing, forward does not work, deep links lead to an unexpected stack.
Therefore, it is better not to fasten web navigation at the end. If the application potentially has a web version, the route model should be designed as URL-first or at least URL-aware from the very beginning.
Seventh stage: mixing Router and Navigator without rules
In Flutter, you can combine declarative routing and direct calls to Navigator.push(). It's not forbidden.
But if you do this without rules, very unpleasant bugs appear.
For example:
- some of the screens are page-backed and participate in the URL;
- some of the screens are open via Navigator.push();
- deeplink changes the page-backed stack;
- pageless routes may disappear after the page-backed route is deleted.;
- modal screens behave differently than regular screens.
Because of this, an explicit agreement is needed.:
- which screens are route-level pages;
- which screens can be opened as modal/dialog/bottom sheet;
- what should be deep-linkable;
- what should not be included in the URL;
- where is imperative push allowed?;
- where navigation should only go through the router.
Without such rules, the team quickly gets a hybrid that cannot be predicted normally.
Good navigation starts with a map
Before choosing It is useful for libraries to draw a route map.
For example:
/
├── /login
├── /onboarding
└── /app
├── /catalog
│ └── /product/:id
│ └── /reviews
├── /search
└── /profile
└── /settingsIt is already visible in this diagram:
- where is the public area;
- where is the private area;
- which routes should be deep-linkable;
- where are the nested stacks needed?;
- what should happen during logout;
- where is the bottom navigation;
- which parameters are part of the URL.
After that, the choice between Navigator, go_router, auto_route or your own implementation becomes much calmer. You choose a tool for the model, rather than building a model around random transitions.
What should be in production navigation?
The minimum set of questions:
1. Is there a single route table?
If route names, paths, and transitions are scattered throughout the project, navigation has already begun to spread.
2. Are there rules for auth?
We don't push login somewhere, but rather the rules for accessing routes.
3. Is there a deeplink strategy?
Each external link should build a predictable application state.
4. Are there separate stacks for tabs?
If there are complex scenarios inside tabs in the bottom navigation application, most likely, one common stack will not hold up.
5. Are there rules for modal routes?
Dialog, bottom sheet, fullscreen modal, and regular page are different things. They should not be thoughtlessly mixed.
6. Is there a web strategy?
Hash URL or path URL? How is the server configured? What happens when you refresh? How does back/forward work?
7. Is there any testability?
Navigation must be verifiable: in this state of the application, such a route is expected.
Example: the problem is not with the router, but with the lack of a model.
Let's say you need to open an order after clicking on the push notification.:
/orders/123A bad approach:
Navigator.of(context).pushNamed('/order', arguments: 123);Why is it bad? Because he doesn't answer questions.:
- is the user logged in?
- if not, where do we save the intended destination?
- after login, do we open the order or home?
- if the order belongs to another organization, do we switch the workspace?
- if the user is already deep in another stack, do we replace the stack or push it from above?
- what will happen with the back?
It is better to think not how to go to the order screen, but how the application should come to a state where the private zone is active, the desired workspace is selected and route /orders/123 is open.
This is architecture, not a button.
Practical rules
Don't start a complex project with chaotic push/pop. It's normal for a prototype. For a product with auth, deeplinks, and tabs, there is almost always no.
Don't make login an ordinary screen that anyone pushes from anywhere. Authorization is a rule for accessing the route tree.
Don't think of bottom navigation as just an index. Tabs often need to have their own stack.
Don't screw up the deeplinks at the end. They reveal all the implicit assumptions about stacks.
Not everything has to be a route. Dialog, bottom sheet, and temporary flow can be pageless/modal if they don't need to be opened via a link.
Not everything has to be modal. If the screen is to have a URL, recover, and participate in the back stack, it must be a full-fledged route.
First the route map, then the library. Otherwise, you will choose not the instrument, but the style of future pain.
The main thing
Navigation in Flutter breaks down sooner than it seems, because it looks simple for a very long time.
While there is only push and pop, it seems that there is no architecture here. But as soon as deeplinks, authorization, bottom tabs, web, push notifications and status recovery appear, navigation becomes one of the central parts of the application.
Good navigation is not what we use go_router for. And we don't use Navigator 2.0. And we don't have all routes named.
Good navigation is when the application has a clear model.:
- what routes exist?;
- what is a public contract?;
- where stack-and live;
- what does back do;
- how deeplinks work;
- how auth affects the route tree;
- what should be page-backed and what can be modal.
When this model is there, the library becomes a tool. When it's not there, any library turns into a more difficult way to do push.
Навигация во Flutter почти всегда начинается безобидно.
Сначала есть два экрана:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const DetailsPage(),
),
);Потом появляется авторизация. Потом bottom navigation. Потом deeplink из push-уведомления. Потом web-версия. Потом экран нужно открыть поверх текущей вкладки, но сохранить состояние старой. Потом пользователь нажимает системную кнопку назад, и внезапно приложение ведёт себя не так, как ожидалось.
Именно в этот момент становится понятно: навигация ломается не тогда, когда в приложении очень много экранов. Она ломается гораздо раньше — когда у команды нет ясной модели, что такое route, кто владеет stack-ом и как URL, состояние приложения и UI связаны между собой.
Главная мысль
Навигация — это не просто переход между экранами.
В нормальном приложении навигация отвечает сразу за несколько вещей:
- какой экран открыт;
- какой stack за ним стоит;
- можно ли попасть на этот экран по ссылке;
- что произойдёт при back;
- как работает авторизация;
- сохраняется ли состояние вкладок;
- как приложение восстанавливается после убийства процесса;
- что видит пользователь на web;
- что делать с push/deeplink;
- где должны жить модальные экраны.
Если всё это держится на разрозненных Navigator.push(), навигация рано или поздно превращается в скрытую архитектуру, которую никто явно не проектировал.
Первая стадия: imperative navigation — нормально, пока приложение маленькое
Navigator.push() и Navigator.pop() — отличный инструмент для простых сценариев.
Например:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProductPage(productId: product.id),
),
);Такой код понятен, локален и не требует лишней инфраструктуры.
Проблема начинается, когда этот стиль становится единственной моделью навигации во всём приложении.
Например:
if (user == null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginPage()),
);
} else if (!user.isOnboarded) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const OnboardingPage()),
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomePage()),
);
}На первый взгляд всё ещё нормально. Но здесь уже скрыта важная проблема: навигация стала следствием состояния приложения, но описана как набор команд.
А команды плохо отвечают на вопрос: какой экран должен быть показан при таком состоянии?
Они отвечают только на вопрос: какое действие выполнить сейчас?
Это разные вещи.
Вторая стадия: named routes дают порядок, но не решают архитектуру
Следующий шаг обычно такой:
Navigator.of(context).pushNamed('/product');Named routes делают код аккуратнее. Не нужно везде создавать MaterialPageRoute, можно держать карту маршрутов в одном месте.
Но у этого подхода есть ограничения. Самое важное: named routes плохо масштабируются для сценариев, где URL, deeplink, web browser history и состояние приложения должны синхронно определять stack экранов.
Например, если приложение получило deeplink /product/42/reviews, недостаточно просто пушнуть экран. Нужно понять:
- пользователь авторизован или нет;
- можно ли ему видеть этот продукт;
- нужно ли открыть tab Catalog;
- должен ли под reviews лежать product details;
- что будет при back;
- как этот URL должен выглядеть на web;
- нужно ли восстановить старый stack вкладки.
Named routes дают имя переходу. Но они не всегда дают полноценную модель навигационного состояния.
Третья стадия: deeplink внезапно показывает, что stack был неявным
Deeplink — это момент истины.
Пока пользователь ходит по приложению руками, stack строится постепенно:
- Home
- Catalog
- Product
- Reviews
Но deeplink хочет открыть сразу:
/product/42/reviewsИ тут появляется вопрос: что должно быть в navigation stack?
Только ReviewsPage? ProductPage + ReviewsPage? HomePage + CatalogPage + ProductPage + ReviewsPage? Или вообще отдельная модальная ветка?
Если у вас нет ответа, deeplink будет работать случайно.
Вот почему deeplink — это не добавить обработчик ссылки. Это проверка, умеет ли приложение построить навигационное состояние из внешнего адреса.
Четвёртая стадия: bottom navigation ломает единственный stack
Bottom navigation часто выглядит просто:
BottomNavigationBar(
currentIndex: index,
onTap: (value) {
setState(() {
index = value;
});
},
)Для простого переключения вкладок этого достаточно.
Но в продукте быстро возникает ожидание: у каждой вкладки должен быть свой stack.
Например:
- во вкладке Catalog пользователь открыл товар;
- потом ушёл во вкладку Profile;
- вернулся в Catalog;
- ожидает увидеть тот же товар, а не начальный экран каталога.
Если у приложения один общий Navigator, начинаются компромиссы. Либо вкладки теряют состояние, либо back начинает вести себя странно, либо код обрастает условиями.
Правильная модель здесь обычно такая: у каждой основной вкладки есть собственный nested navigator или декларативная ветка маршрутов, которая сохраняет свой stack.
И это нужно проектировать заранее. Иначе bottom navigation превращается в источник самых раздражающих багов: назад закрывает не то, вкладка сбрасывается, экран открывается поверх неправильной ветки.
Пятая стадия: auth guard — это не кнопка перейти на login
Авторизация — ещё один частый источник хаоса.
Плохая модель:
if (!isLoggedIn) {
Navigator.of(context).pushNamed('/login');
}Почему плохая? Потому что авторизация — это не локальное действие одного экрана. Это глобальное условие доступа к части дерева маршрутов.
Правильнее думать так:
- если пользователь не авторизован, приватные маршруты недоступны;
- если пользователь авторизован, login/register больше не должны быть обычной точкой входа;
- после login нужно вернуться туда, куда пользователь хотел попасть;
- после logout нужно сбросить приватный stack;
- deeplink на приватный экран должен пройти через auth flow.
То есть auth guard — это не push('/login'). Это правило преобразования навигационного состояния.
Шестая стадия: web делает навигацию публичным контрактом
На mobile приложение может долго жить с внутренними маршрутами, которые видит только код.
На web URL становится частью продукта.
Пользователь ожидает, что:
- страницу можно обновить;
- ссылку можно скопировать;
- browser back/forward работает;
- URL соответствует открытому экрану;
- путь без # работает при правильной серверной настройке;
- deeplink не ломает текущую структуру приложения.
Если приложение выросло из набора Navigator.push(), web часто вскрывает всё сразу: URL не соответствует экрану, refresh открывает не то, forward не работает, deep links ведут в неожиданный stack.
Поэтому web-навигацию лучше не прикручивать в конце. Если у приложения потенциально будет web-версия, route model стоит проектировать как URL-first или хотя бы URL-aware с самого начала.
Седьмая стадия: смешивание Router и Navigator без правил
Во Flutter можно сочетать декларативную маршрутизацию и прямые вызовы Navigator.push(). Это не запрещено.
Но если делать это без правил, появляются очень неприятные баги.
Например:
- часть экранов page-backed и участвует в URL;
- часть экранов открыта через Navigator.push();
- deeplink меняет page-backed stack;
- pageless routes после удалённой page-backed route могут исчезнуть;
- модальные экраны ведут себя иначе, чем обычные.
Из-за этого нужна явная договорённость:
- какие экраны являются route-level pages;
- какие экраны можно открывать как modal/dialog/bottom sheet;
- что должно быть deep-linkable;
- что не должно попадать в URL;
- где разрешён imperative push;
- где навигация должна идти только через router.
Без таких правил команда быстро получает гибрид, который нельзя нормально предсказать.
Хорошая навигация начинается с карты
Перед выбором библиотеки полезно нарисовать маршрутную карту.
Например:
/
├── /login
├── /onboarding
└── /app
├── /catalog
│ └── /product/:id
│ └── /reviews
├── /search
└── /profile
└── /settingsУже на этой схеме видно:
- где публичная зона;
- где приватная зона;
- какие маршруты должны быть deep-linkable;
- где нужны вложенные stack-и;
- что должно происходить при logout;
- где bottom navigation;
- какие параметры являются частью URL.
После этого выбор между Navigator, go_router, auto_route или собственной реализацией становится намного спокойнее. Вы выбираете инструмент под модель, а не строите модель вокруг случайных переходов.
Что должно быть в production-навигации
Минимальный набор вопросов:
1. Есть ли единая таблица маршрутов?
Если route names, paths и transitions разбросаны по проекту, навигация уже начала расползаться.
2. Есть ли правила для auth?
Не где-то пушим login, а именно правила доступа к маршрутам.
3. Есть ли стратегия для deeplink?
Каждая внешняя ссылка должна строить предсказуемое состояние приложения.
4. Есть ли отдельные stack-и для вкладок?
Если в приложении bottom navigation и сложные сценарии внутри вкладок, скорее всего, один общий stack не выдержит.
5. Есть ли правила для modal routes?
Dialog, bottom sheet, fullscreen modal и обычная page — разные вещи. Их нельзя бездумно смешивать.
6. Есть ли web-стратегия?
Hash URL или path URL? Как настроен сервер? Что будет при refresh? Как работает back/forward?
7. Есть ли тестируемость?
Навигация должна быть проверяемой: при таком состоянии приложения ожидается такой маршрут.
Пример: проблема не в router, а в отсутствии модели
Допустим, после нажатия на push-уведомление нужно открыть заказ:
/orders/123Плохой подход:
Navigator.of(context).pushNamed('/order', arguments: 123);Почему плохой? Потому что он не отвечает на вопросы:
- пользователь авторизован?
- если нет, куда сохраняем intended destination?
- после login открываем заказ или home?
- если заказ относится к другой организации, переключаем workspace?
- если пользователь уже находится глубоко в другом stack, заменяем stack или пушим сверху?
- что будет при back?
Лучше думать не как перейти на экран заказа, а как приложение должно прийти к состоянию, где активна приватная зона, выбран нужный workspace и открыт route /orders/123.
Это уже архитектура, а не кнопка.
Практические правила
Не начинайте сложный проект с хаотичных push/pop. Для прототипа нормально. Для продукта с auth, deeplinks и tabs — почти всегда нет.
Не делайте login обычным экраном, который кто угодно пушит откуда угодно. Авторизация — это правило доступа к дереву маршрутов.
Не считайте bottom navigation просто индексом. У вкладок часто должен быть собственный stack.
Не прикручивайте deeplinks в конце. Они вскрывают все неявные допущения о stack-е.
Не всё должно быть route. Dialog, bottom sheet и temporary flow могут быть pageless/modal, если их не нужно открывать по ссылке.
Не всё должно быть modal. Если экран должен иметь URL, восстанавливаться и участвовать в back stack, он должен быть полноценным route.
Сначала маршрутная карта, потом библиотека. Иначе вы будете выбирать не инструмент, а стиль будущей боли.
Главное
Навигация во Flutter ломается раньше, чем кажется, потому что она очень долго выглядит простой.
Пока есть только push и pop, кажется, что архитектуры здесь нет. Но как только появляются deeplinks, авторизация, bottom tabs, web, push-уведомления и восстановление состояния, навигация становится одной из центральных частей приложения.
Хорошая навигация — это не мы используем go_router. И не мы используем Navigator 2.0. И не у нас все routes named.
Хорошая навигация — это когда у приложения есть понятная модель:
- какие маршруты существуют;
- что является публичным контрактом;
- где живут stack-и;
- что делает back;
- как работают deeplinks;
- как auth влияет на route tree;
- что должно быть page-backed, а что может быть modal.
Когда эта модель есть, библиотека становится инструментом. Когда её нет, любая библиотека превращается в способ сложнее делать push.