Skip to main content

Fixing Broken Back Navigation in Flutter Deep Links with GoRouter

 

The Pain Point

You have implemented deep linking in your Flutter application. Clicking a URL like myapp://shop/product/42 correctly launches the app and navigates directly to the Product Details screen.

However, when the user presses the Back button (or uses the system back gesture), the app closes immediately.

Instead of returning to the Home feed or Dashboard, the user is dumped back to the OS home screen. From a UX perspective, this is a critical failure. The user expects to enter the app via a specific room and explore the rest of the house, not be locked in that room with the only exit leading outside.

The Root Cause: Declarative State vs. Imperative History

To fix this, you must understand how GoRouter differs from the classic Navigator.

In the imperative world (Navigator.push), navigation is a mutable stack of history. You push A, then B. The history is [A, B].

GoRouter is declarative. It treats the router state as a function of the URL configuration. When the app launches via a deep link myapp://shop/product/42, the operating system hands that string to Flutter. GoRouter matches that URL against your route tree.

If your route configuration defines /product/:id as a top-level route (a sibling of /), GoRouter constructs a navigation stack containing only that single route.

The Stack in Sibling Configuration:

[ /product/42 ]

When pop() is called on a stack of size 1, Flutter delegates the pop to the native platform, which interprets it as "Finish Activity" or "Close Application."

To ensure a "Back" button exists, we must tell GoRouter that the /product/:id route is logically a child of the Home route, forcing the router to synthesize a stack that includes the parent.

The Fix: Route Nesting

The solution relies on the routes property within a GoRoute. By defining the detail route inside the home route, we enforce a strict hierarchical relationship. When GoRouter parses the deep link, it builds the stack from the root down to the leaf.

Prerequisites

Ensure you are running a modern version of GoRouter (v13.0.0 or higher).

The Implementation

Below is a complete, runnable routing configuration. Note the structure of the routes list.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const ShopApp());
}

class ShopApp extends StatelessWidget {
  const ShopApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      title: 'Deep Link Navigation Fix',
    );
  }
}

// ---------------------------------------------------------------------------
// ROUTER CONFIGURATION
// ---------------------------------------------------------------------------

final GoRouter _router = GoRouter(
  initialLocation: '/',
  routes: <RouteBase>[
    // 1. The Parent Route (Home)
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const HomeScreen();
      },
      // 2. The CRITICAL Fix: Define the detail route as a child here.
      routes: <RouteBase>[
        GoRoute(
          // Note: Do not add a leading slash for child routes.
          // This creates the path '/product/:id' relative to root.
          path: 'product/:id', 
          builder: (BuildContext context, GoRouterState state) {
            final String id = state.pathParameters['id'] ?? 'unknown';
            return ProductDetailScreen(productId: id);
          },
        ),
      ],
    ),
  ],
);

// ---------------------------------------------------------------------------
// UI COMPONENTS
// ---------------------------------------------------------------------------

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Shop Home')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Welcome to the Shop'),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // Standard navigation
                context.go('/product/101');
              },
              child: const Text('View Product 101'),
            ),
          ],
        ),
      ),
    );
  }
}

class ProductDetailScreen extends StatelessWidget {
  final String productId;

  const ProductDetailScreen({
    required this.productId,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // The AppBar automatically renders a Back button because 
      // GoRouter detects a history stack > 1.
      appBar: AppBar(title: Text('Product $productId')),
      body: Center(
        child: Text(
          'Deep Link Success!\n\n'
          'Press Back to return to Home.\n'
          'Current Route: /product/$productId',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

How It Works

When the app is opened via the deep link myapp://shop/product/99:

  1. Route Matching: GoRouter traverses the tree. It sees path: '/'. It knows this is the root.
  2. Child Matching: It looks inside routes of / and finds product/:id. It matches this against the URL segment.
  3. Stack Synthesis: Because of this parent-child definition, GoRouter constructs the page stack logically:
    Stack: [ HomeScreen, ProductDetailScreen ]
    
  4. Rendering: ProductDetailScreen is rendered on top.
  5. Navigation: When the user presses Back, GoRouter pops ProductDetailScreen. The stack is not empty; HomeScreen remains. The user lands safely on the Home page.

Important Caveat: Path Concatenation

When nesting routes, paths are concatenated.

  • Parent path: /
  • Child path: product/:id
  • Resulting URL: /product/:id

If your parent path was /shop, and the child was details, the URL would be /shop/details. If you need absolute paths that don't look like the tree structure, you have to use ShellRoute or imperative overrides, but standard nesting is the most robust way to guarantee a valid back stack.

Conclusion

The "app closing on back" bug is almost always a symptom of flat routing configurations in a declarative system. By treating your detail views as children of your main views in the GoRouter configuration, you ensure that the router automatically synthesizes the correct navigation history, regardless of whether the user arrived via a button tap or an external deep link.