Designing Navigation Pages That Actually Survive Refresh
A practical approach to cross-platform routing in Flutter

Problem statement: it works… until someone refreshes
I’ve seen this pattern again and again, especially with newer Flutter developers.
You build navigation using go_router.
You pass objects from one page to another.
Everything works perfectly during normal app usage.
Then someone refreshes the page.
Or opens a link directly.
Or lands on a URL from Google.
And suddenly:
the page crashes
or shows a blank screen
or awkwardly redirects back to a list
At this point, most people start adding workarounds:
“if data is null, go back”
“if object is missing, fetch again”
“if state is gone, redirect home”
The router isn’t the problem here.
The page design is.
What’s actually going wrong
The root issue is tight coupling between pages.
A lot of pages are built with the assumption that:
“This page will always be opened from its parent.”
That assumption is no longer safe.
With Navigator 2.0, deep linking is not optional anymore.
On web, every page is already a deep link — whether you planned for it or not.
If a page depends on parent state to exist, that page is fragile by design.
One simple question that changes everything
Whenever I add a new route, I ask myself one question:
From a business point of view, should this page be able to exist on its own?
This question decides everything.
Standalone pages
Examples:
/user/:id/order/:id/product/:id
These pages:
should open directly from a URL
should survive refresh
should not depend on parent state
should be able to load themselves
Flow-dependent pages
Examples:
payment steps
checkout confirmation
OTP verification
onboarding flows
These pages:
only make sense inside a flow
often should not be deep linked
can depend on parent state
usually redirect if opened directly
Not every page needs to be standalone.
But the ones that should be, must be designed that way from day one.
The common mistake: passing full objects through routes
Let’s look at a very common pattern.
Fragile approach
// From user list page
context.go(
'/user-details',
extra: user,
);
// User details page
class UserDetailsPage extends StatelessWidget {
final User user;
const UserDetailsPage({required this.user});
@override
Widget build(BuildContext context) {
return Text(user.name);
}
}
This feels convenient.
No extra API call.
No loading state.
But this page has one big problem:
It cannot exist on its own.
If the user refreshes:
extrais goneuseris nullnow the page doesn’t know what to do
So people start patching:
redirect back to list
show error
refetch with hacks
All of this complexity exists because the page depends on navigation state.
A more resilient approach: routes as contracts
If a page is standalone, the route should describe what the page needs, not what the parent happens to have.
Better route definition
GoRoute(
path: '/user/:id',
builder: (context, state) {
final userId = state.pathParameters['id']!;
return UserDetailsPage(userId: userId);
},
);
Now the route itself answers one question clearly:
“What does this page need to load?”
Answer: a userId.
Move data responsibility into the page
The page should not care where it came from.
It should only care about what it needs.
class UserDetailsPage extends StatelessWidget {
final String userId;
const UserDetailsPage({required this.userId});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => UserDetailsCubit(userId)..load(),
child: const UserDetailsView(),
);
}
}
class UserDetailsCubit extends Cubit<UserDetailsState> {
final String userId;
UserDetailsCubit(this.userId) : super(UserDetailsLoading());
Future<void> load() async {
final user = await userRepository.getUser(userId);
emit(UserDetailsLoaded(user));
}
}
Now this page:
works on refresh
works with deep links
works without parent context
is easy to reason about
“But why make another API call?” — the hybrid approach
This is a fair question.
If I already have the user object, why fetch it again?
You don’t have to.
Hybrid page design
Let the page accept an optional object without depending on it.
class UserDetailsPage extends StatelessWidget {
final String userId;
final User? user;
const UserDetailsPage({
required this.userId,
this.user,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => UserDetailsCubit(
userId: userId,
initialUser: user,
)..load(),
child: const UserDetailsView(),
);
}
}
class UserDetailsCubit extends Cubit<UserDetailsState> {
final String userId;
final User? initialUser;
UserDetailsCubit({
required this.userId,
this.initialUser,
}) : super(UserDetailsLoading());
Future<void> load() async {
if (initialUser != null) {
emit(UserDetailsLoaded(initialUser!));
return;
}
final user = await userRepository.getUser(userId);
emit(UserDetailsLoaded(user));
}
}
What you gain
No unnecessary API calls
Refresh still works
Deep links still work
Page is not tied to navigation history
This pattern scales very well as the app grows.
What about flow-based pages?
Not every page needs this treatment.
For example, a payment confirmation step:
doesn’t make sense outside the flow
probably should redirect if opened directly
can depend on parent state safely
The key is being intentional.
Problems happen when we treat every page the same.
Why this matters long-term
Pages that can load themselves:
are easier to debug
are easier to test
are easier for new developers to understand
break less often on web
Most “navigation bugs” are actually page design bugs.
Final thoughts
Navigation is not just about moving between screens.
It’s about defining boundaries and responsibilities.
Before adding a route, ask:
Should this page survive refresh?
That one question will save you a lot of pain later.





