Dialogs are a great way to show popups but when you use them with GoRouter the back button closes the Dialog and takes you back to the previous route. This flutter gorouter dialog navigation issue is not acceptable from user experience perspective when compared to other web apps in production.
This is a known and open issue as well. Since it is marked as P3 in priority and reported in October 2022, we can safely assume that it is not getting picked up soon.
We are going to take a very simple example of 2 page routes.
- /products
- /products/delete-product (This is going to be our dialog page with its own URL)
Table of Contents
GoRouter dialog navigation issue: What the problem looks like

You can see that the URL in the browser is changed to /products when we navigate to the Products route/page.
Tapping on delete opens up a dialog and tapping the browser’s back button does 2 things:
- Removes the dialog
- Pops the route as well rather than staying on the /products page
The Solution: Using Dialogs as Page Routes
If you just the need the full code example, scroll to the end of the post.
1. Prevent the page/route from popping with the dialog
Instead of using showDialog
, we can treat dialogs as pages by wrapping them inside Page
and defining them within GoRouter
. This ensures that dialogs are properly handled by the router, meaning they respect navigation history and can be controlled just like any other route.
Implementing DialogPage<T>
class DialogPage<T> extends Page<T> {
final Offset? anchorPoint;
final Color? barrierColor;
final bool barrierDismissible;
final String? barrierLabel;
final bool useSafeArea;
final CapturedThemes? themes;
final WidgetBuilder builder;
const DialogPage({
required this.builder,
this.anchorPoint,
this.barrierColor = Colors.black54,
this.barrierDismissible = false,
this.barrierLabel,
this.useSafeArea = true,
this.themes,
super.key,
super.name,
super.arguments,
super.restorationId,
});
@override
Route<T> createRoute(BuildContext context) => DialogRoute<T>(
context: context,
settings: this,
builder: builder,
anchorPoint: anchorPoint,
barrierColor: barrierColor,
barrierDismissible: barrierDismissible,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
themes: themes);
}
Setting GoRouter
to Use DialogPage
GoRoute(
path: 'delete-product',
pageBuilder: (BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => DeleteProduct(product: state.extra as Product?));
},
),
With this fix, you should be able to see the dialog as a page with its own URL.

2. Prevent the user from opening the dialog again by tapping the browser’s forward button or by copying the URL
This is important in some cases for example, let’s consider a delete dialog having details of a product. Imagine the delete flow is popped by the browser’s back button and the dialog is dismissed. Remember that now your dialog is a part of browser’s navigation history and the user can still use the browser’s forward button to come back to a delete product flow which you might not want since ‘delete’ in general is a destructive flow.
For this, firstly you need to pass some data in the ‘extra
‘ parameter during your call to context.go
. Check the onPressed
of the IconButton in this Products
widget.
Implementing Products
Widget
class Products extends StatefulWidget {
const Products({super.key});
@override
State<Products> createState() => _ProductsState();
}
class _ProductsState extends State<Products> {
final List<Product> _products = [
Product(id: '1', title: 'Laptop', price: 999.99),
Product(id: '2', title: 'Smartphone', price: 699.99),
Product(id: '3', title: 'Headphones', price: 199.99),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
),
body: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
title: Text(product.title),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => context.go('/products/delete-product', extra: product) //Usage of extra parameter
),
);
},
),
);
}
}
This extra parameter is then passed to the DeleteProduct widget via go router’s configuration.
It’s important to note that while using extra parameter, do not use plain data types like strings, bool, int, etc because browser does store them in the navigation history.
Just wrap them in you dart class and pass it in the extra parameter. The whole idea of using an extra parameter is that it will be passed when your dart code opens the route, not by the browser’s navigation or copy pasting the URL.
Full GoRouter
Configuration
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) {
return const Wishlist();
},
routes: [
GoRoute(
path: 'products',
builder: (context, state) {
return const Products();
},
routes: [
GoRoute(
path: 'delete-product',
pageBuilder: (BuildContext context, GoRouterState state) {
//Wrapping DeleteProduct in DialogPage
return DialogPage(builder: (_) => DeleteProduct(product: state.extra as Product?));
},
),
]
),
]),
],
);
At this point you are passing product
object in the extra
parameter and then your go router further passes it to the constructor of the DeleteProduct
widget.
From here we simply need to check in the builder of the DeleteProduct
widget whether this extra parameter exists or not.
Implementing DeleteProduct
Widget
In the builder itself we will put checks that if GoRouterState.of(context).extra
is null then pop with a Router.neglect
so that this pop is not recorded in the browser’s navigation history.
class DeleteProduct extends StatelessWidget {
final Product? product;
const DeleteProduct({super.key, required this.product});
@override
Widget build(BuildContext context) {
final extra = GoRouterState.of(context).extra;
// If accessed with browser's forwaed button (no 'extra' data will exist), navigate back programmatically
if (extra == null) {
// Use addPostFrameCallback to avoid build-time navigation
WidgetsBinding.instance.addPostFrameCallback((_) {
Router.neglect(context, () => context.pop());
});
// Return an empty container while redirecting
return Container();
} else {
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
color: Colors.black.withAlpha(100),
child: Center(
child: Container(
margin: const EdgeInsets.all(32),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Confirm Delete',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Text(
'Product: ${product!.title}',
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 8),
Text(
'Price: \$${product!.price.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 24),
const Text(
'Are you sure you want to delete this product?',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
Router.neglect(context, () => context.pop());
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
),
],
),
),
),
),
);
}
}
}
You can even create a mixin so that you don’t need to write this in all dialogs.
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
//Pullback the current route if extra param is null
mixin PullBackRoute<T extends StatefulWidget> on State<T> {
pullBackRoute() {
final extra = GoRouterState.of(context).extra;
// If accessed directly (no extra data), navigate back programmatically
if (extra == null) {
// Use addPostFrameCallback to avoid build-time navigation
WidgetsBinding.instance.addPostFrameCallback((_) {
Router.neglect(context, () => context.pop());
});
// Return an empty container while redirecting
return Container();
}
}
}
Implement this mixin on your widget and then simply call pullBackRoute in the build method of your widget.
@override
Widget build(BuildContext context) {
pullBackRoute();
Does this solves all the problems with GoRouter dialog navigation?
Sadly no, one problem still remains. When you pop with Router.neglect the browser’s navigation still makes an entry but its an empty entry so nothing will happen when you tap forward button after tapping the backward button. I don’t think this is a big issue as of now.
Full Code Gist for fixing GoRouter dialog navigation
Here is the gist of the full code example. Just replace your main.dart file content with it and play with it.
Enjoy !