Skip to main content

Command Palette

Search for a command to run...

Designing Navigation Pages That Actually Survive Refresh

A practical approach to cross-platform routing in Flutter

Updated
5 min read
Designing Navigation Pages That Actually Survive Refresh

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:

  • extra is gone

  • user is null

  • now 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.