May 17, 2024

Mastering Layout Design for Flutter Web: A Step-by-Step Guide

Get a comprehensive understanding of Flutter Web layout design and manage shared UI elements using the go_router package in this detailed guide.

Author

Ashish Rajesh GourSenior Software Engineer - II

Subject Matter Expert

Shubham KumarShubham KumarTech Lead - II
Mastering Layout Design for Flutter Web: A Step-by-Step Guide

While building user interfaces in Flutter Web, we often create them by combining independent widgets. But some UI elements, like the navigation bar, bottom navigation bar, side drawer, etc. repeat across different screens.

To handle this, Flutter uses layouts to organise the interface and hold these shared elements. In this article, we will explore managing layouts and nested layouts in Flutter Web using the go_router package.

GoRouter is a declarative routing package that simplifies navigation between different screens within your app. It leverages the built-in Router API to define routes using URL-like patterns. This allows you to programmatically navigate to specific screens by providing a URL-like string, making your code more readable and maintainable.


Getting Started

Assuming you already have Flutter installed, let us dive into creating your application! We will begin by incorporating the go_router package into your pubspec.yaml file. Throughout this guide, we will be using version ^14.1.1.

dependencies:
go_router: ^14.1.1


Route Configuration

Once you have included the go_router package in your pubspec.yaml dependencies, you can proceed with setting up the GoRouter configuration within your Flutter application.


Route Utils

Let us create enum class inside the app_routes.dart file that contains all the screens that our application has.

enum AppRoutes {
  home,
  profile,
  profileList,
  settings,
  subSetting,
}

For navigation in our Flutter application, we will define a mechanism to manage routing information. This involves creating an AppRouter class to define all our routes. 

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../screens/profile_screen.dart';
import '../screens/nested_navigation_wrapper.dart';
import '../screens/settings_view.dart';
import '../screens/profile_list_screen.dart';
import '../screens/sub_setting_view.dart';
import 'app_routes.dart';

class AppRouter {
  AppRouter._();

  static final GlobalKey<NavigatorState> routeNavigatorKey = GlobalKey();
  static String initialRoute = '/';
  static final shellNavigatorProfile =
      GlobalKey<NavigatorState>(debugLabel: 'shellProfile');
  static final shellNavigatorSettings =
      GlobalKey<NavigatorState>(debugLabel: 'shellSettings');

  static final GoRouter router = GoRouter(
    navigatorKey: routeNavigatorKey,
    initialLocation: initialRoute,
    errorBuilder: (context, state) =>
        ErrorScreen(message: state.error?.message ?? ""),
    routes: [
      GoRoute(
        path: '/',
        builder: (BuildContext context, state) => const LoginScreen(),
      ),
      StatefulShellRoute.indexedStack(
        builder: (context, state, navigationShell) {
          return NestedNavigationWrapper(
            navigationShell: navigationShell,
          );
        },
        branches: <StatefulShellBranch>[
          /// Profile Route
          StatefulShellBranch(
            navigatorKey: shellNavigatorProfile,
            routes: <RouteBase>[
              GoRoute(
                path: "/${AppRoutes.profile.name}",
                name: AppRoutes.profile.name,
                builder: (BuildContext context, GoRouterState state) =>
                    const ProfileScreen(),
                routes: [
                  GoRoute(
                    path: AppRoutes.profileList.name,
                    name: AppRoutes.profileList.name,
                    pageBuilder: (context, state) => CustomTransitionPage<void>(
                      key: state.pageKey,
                      child: const ProfileListScreen(),
                      transitionsBuilder:
                          (context, animation, secondaryAnimation, child) =>
                              FadeTransition(opacity: animation, child: child),
                    ),
                  ),
                  GoRoute(
                    path:
                        "${AppRoutes.profileList.name}/subprofile/:profileId", // Use ":itemId" for dynamic value
                    name: "subProfileList",
                    builder: (BuildContext context, GoRouterState state) {
                      final itemId =
                          int.tryParse(state.pathParameters['profileId']!) ?? 0;

                      return Center(
                        child: Text(
                          'Navigated to profile $itemId',
                          style: const TextStyle(
                              fontSize: 20, fontWeight: FontWeight.bold),
                        ),
                      );
                    },
                  ),
                ],
              ),
            ],
          ),

          /// Settings Route
          StatefulShellBranch(
            navigatorKey: shellNavigatorSettings,
            routes: <RouteBase>[
              GoRoute(
                path: "/${AppRoutes.settings.name}",
                name: AppRoutes.settings.name,
                builder: (BuildContext context, GoRouterState state) =>
                    const SettingsScreen(),
                routes: [
                  GoRoute(
                    path: AppRoutes.subSetting.name,
                    name: AppRoutes.subSetting.name,
                    pageBuilder: (context, state) {
                      return CustomTransitionPage<void>(
                        key: state.pageKey,
                        child: const SubSettingsScreen(),
                        transitionsBuilder: (
                          context,
                          animation,
                          secondaryAnimation,
                          child,
                        ) =>
                            FadeTransition(opacity: animation, child: child),
                      );
                    },
                  ),
                  // Define sub-route for specific item with dynamic parameter
                  GoRoute(
                    path:
                        "${AppRoutes.subSetting.name}/item/:itemId", // Use ":itemId" for dynamic value
                    name: "subSettingItem",
                    builder: (BuildContext context, GoRouterState state) {
                      final itemId =
                          int.tryParse(state.pathParameters['itemId']!) ?? 0;

                      return Center(
                        child: Text(
                          'Navigated to item $itemId',
                          style: const TextStyle(
                              fontSize: 20, fontWeight: FontWeight.bold),
                        ),
                      );
                    },
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
 
}

GoRouter excels at handling both standard and nested navigation in your Flutter application. While standard navigation switches between entirely different screens, nested navigation allows you to dynamically choose widgets within a parent screen. Crucially, during nested navigation, any persistent UI elements, like a bottom navigation bar, remain unchanged, providing a seamless user experience. In GoRouter, a Shell Route is a special type of route designed to manage applications with a persistent UI layout, typically seen in mobile and web apps. This shell (layout) usually contains elements like a navigation bar, footer, or sidebar that remain constant across different screens within the app.


Why Use Shell Routes?

Shell routes leverage these layout benefits by creating a dedicated layout for the persistent UI elements. This layout:

  • Preserves state: Since the shell route acts as a container, its layout (navigation bar, footer, etc.) remains in memory and doesn't need to be recreated on navigation. This improves performance and avoids unnecessary UI refreshes.
  • Stays interactive: The shell layout's interactive elements continue to function throughout navigation within the section managed by the shell route.
  • Allows nesting: Shell routes can be nested within other shell routes, enabling you to create complex navigation hierarchies with consistent layouts throughout the app.

In essence, shell routes provide a structured and efficient way to manage navigation in applications with a persistent UI shell. They leverage the concept of layouts to maintain state, interactivity, and performance during navigation.


Key Points About Nested Navigation

  • Custom NestedNavigationWrapper: We have created a custom widget named NestedNavigationWrapper using StatefulShellRoute.indexedStack. This widget takes a StatefulNavigationShell argument as input.
  • StatefulShellRoute Breakdown:
    • It accepts a list of StatefulShellBranch items, where each branch represents an independent stateful section within the overall navigation tree.
    • Both GoRouter and StatefulShellBranch require a navigatorKey argument. These keys are typically defined within the AppRouter class for centralised management.
  • StatefulShellBranch Flexibility: Each StatefulShellBranch can define its own route hierarchy using the familiar GoRoute API. This allows for granular control over nested routes within each branch.
  • StatefulShellRoute.indexedStack: Utilising StatefulShellRoute.indexedStack provides a builder function. This builder function grants access to the navigationShell object, which is crucial for constructing the shell layout (the visual representation of your nested routes). 
StatefulShellRoute.indexedStack(
        builder: (context, state, navigationShell) {
          return NestedNavigationWrapper(
            navigationShell: navigationShell,
          );
        },
        branches: <StatefulShellBranch>[ ... ],
      ),

You can refer to the AppRouter class for detailed implementation for StatefulShellRoute.indexedStack.

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

class NestedNavigationWrapper extends StatefulWidget {
  const NestedNavigationWrapper({
    required this.navigationShell,
    super.key,
  });
  final StatefulNavigationShell navigationShell;

  @override
  State<NestedNavigationWrapper> createState() =>
      _NestedNavigationWrapperState();
}

class _NestedNavigationWrapperState extends State<NestedNavigationWrapper> {
  int selectedIndex = 0;

  void _goBranch(int index) {
    widget.navigationShell.goBranch(
      index,
      initialLocation: index == widget.navigationShell.currentIndex,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0.0,
        toolbarHeight: 70,
        title: const Text(
          "NESTED NAVIGATION HEADER",
          style: TextStyle(
            color: Colors.white,
            fontSize: 25,
            fontWeight: FontWeight.w900,
          ),
        ),
        centerTitle: true,
        flexibleSpace: Container(
          decoration: const BoxDecoration(
              borderRadius: BorderRadius.only(
                  bottomLeft: Radius.circular(20),
                  bottomRight: Radius.circular(20)),
              gradient: LinearGradient(
                  colors: [Colors.red, Colors.pink],
                  begin: Alignment.bottomCenter,
                  end: Alignment.topCenter)),
        ),
      ),
      body: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: widget.navigationShell,
      ),
      bottomNavigationBar: SlidingClippedNavBar(
        backgroundColor: Colors.redAccent.shade400,
        onButtonPressed: (index) {
          setState(() {
            selectedIndex = index;
          });
          _goBranch(selectedIndex);
        },
        iconSize: 30,
        activeColor: Colors.white,
        inactiveColor: Colors.black,
        selectedIndex: selectedIndex,
        barItems: [
          BarItem(
            icon: Icons.people,
            title: 'PROFILE',
          ),
          BarItem(
            icon: Icons.settings,
            title: 'SETTINGS',
          ),
        ],
      ),
    );
  }
}

Now, let us integrate our profile and settings screens, along with their corresponding nested screens, into the NestedNavigationWrapper widget.

  • Profile Screens
Profile screen
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:web_poc/routes/app_routes.dart';

class ProfileScreen extends StatefulWidget {
  const ProfileScreen({super.key});

  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.person,
              color: Colors.red,
              size: 100,
            ),
            const SizedBox(
              height: 8,
            ),
            const Text(
              "Profile",
              style: TextStyle(fontSize: 30),
            ),
            const SizedBox(
              height: 12,
            ),
            MaterialButton(
              color: Colors.redAccent,
              onPressed: () {
                context.pushNamed(AppRoutes.profileList.name);
              },
              child: const Text(
                "Navigate To profile's list",
                style: TextStyle(color: Colors.white),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Within the profile screen, to navigate to its nested profile list screen, we can utilise GoRouter's context.goNamed(AppRoutes.profileList.name) method.

Navigating to nested profile screen
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class ProfileListScreen extends StatefulWidget {
  const ProfileListScreen({super.key});

  @override
  State<ProfileListScreen> createState() => _ProfileListScreenState();
}

class _ProfileListScreenState extends State<ProfileListScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: ListView.builder(itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () {
              context.go('/profile/profileList/subprofile/$index');
            },
            child: Card(
              child: ListTile(
                leading: Text(
                  index.toString(),
                ),
                title: Text("Profiles $index"),
              ),
            ),
          );
        }),
      ),
    );
  }
}
  • Settings Screens
Setting screens
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import '../routes/app_routes.dart';

class SettingsScreen extends StatefulWidget {
  const SettingsScreen({super.key});

  @override
  State<SettingsScreen> createState() => _SettingsScreenState();
}

class _SettingsScreenState extends State<SettingsScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.settings,
              color: Colors.indigoAccent,
              size: 100,
            ),
            const SizedBox(
              height: 8,
            ),
            const Text(
              "Settings",
              style: TextStyle(fontSize: 30),
            ),
            const SizedBox(
              height: 12,
            ),
            MaterialButton(
              color: Colors.indigoAccent,
              onPressed: () {
                context.pushNamed(AppRoutes.subSetting.name);
              },
              child: const Text(
                "Navigate To sub settings",
                style: TextStyle(color: Colors.white),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Similarly within the settings screen, to navigate to its nested sub settings screen, we can utilise GoRouter's context.pushNamed(AppRoutes.subSetting.name) method.

Nested sub settings screen
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class SubSettingsScreen extends StatefulWidget {
  const SubSettingsScreen({super.key});

  @override
  State<SubSettingsScreen> createState() => _SubSettingsScreenState();
}

class _SubSettingsScreenState extends State<SubSettingsScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: ListView.builder(
          itemBuilder: (context, index) {
            return GestureDetector(
              onTap: () {
                context.go('/settings/subSetting/item/$index');
              },
              child: Card(
                child: ListTile(
                  leading: Text(
                    index.toString(),
                  ),
                  title: Text("Settings no. $index"),
                ),
              ),
            );
          },
          itemCount: 20,
        ),
      ),
    );
  }
}

Check out the demo I created to see nested layouts in action! A nested layout is a UI component that is shared across multiple routes in your application. During navigation, these layouts maintain their state, remain interactive, and avoid unnecessary re-rendering.

Consider the layout we created with a persistent header and bottom navigation bar. This layout acts as a container, allowing you to seamlessly switch between tabs. The profile tab, for instance, can navigate to nested child routes like 'profileList' with a subprofile id. For example, you could see a profile list details page as http://localhost:56219/profile/profileList/subprofile/5. Similarly, the settings tab has its own nested routes like http://localhost:56219/settings/subSetting.

Now, let us consider the following example: tapping the notifications icon in your app. This action triggers a smooth transition to a dedicated notifications screen. This screen has its own distinct layout, optimized for displaying your notifications. Importantly, when you navigate back from the notifications screen, the previous layout (the one you left) remains unchanged, including any state it may have held. This seamless state preservation ensures that you can pick up right where you left off, without losing any context or information.


Conclusion

This guide explored how GoRouter empowers you to structure your Flutter application's UI with shared elements across various routes in your web application.

We have delved into the concept of nested navigation using GoRouter, allowing you to dynamically swap content within a parent screen while maintaining a persistent UI shell (like a bottom navigation bar). This approach ensures a seamless user experience during navigation.

If you found this guide valuable, please share it with your fellow Flutter developers! We encourage you to leave comments or questions below to continue the conversation and contribute to the Flutter development community.

Subscribe to Our Newsletter

RELATED ARTICLES

More from the engineering frontline.

Dive deep into our research and insights on design, development, and the impact of various trends to businesses.
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
A real-world look at how oversized images can trigger Safari reloads and iOS crashes in Flutter apps and how smarter image decoding prevents them.
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
This blog explores how Flutter Agent Skills improve AI-assisted development by combining official framework workflows with project-specific guidance for more consistent development.
Why Everything Your AI Builds Looks the Same
Why Everything Your AI Builds Looks the Same
This blog explores why AI-generated interfaces often look alike and explains how design systems, product context, and reusable engineering practices help teams build distinctive, scalable
How We Built the Missing Bridge from Code to Figma
Technology

Jul 10, 2026

How We Built the Missing Bridge from Code to Figma
This blog explores how AI-generated React apps get turned into fully editable, designer-ready Figma files by reading React Fiber instead of the DOM.
Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
Technology

Jun 27, 2026

Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
A practical breakdown of building resilient AWS-to-on-premises connectivity with WireGuard HA, active-standby failover, and deep packet-forwarding observability.
We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
Technology

Jun 19, 2026

We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
A practical guide to building a 114-second multi-cloud disaster recovery failover between AWS and Azure — what we built, what broke, and what we learned.
Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
Technology

Jun 12, 2026

Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
This blog explains how organizations can balance speed, scalability, and operational flexibility as they grow from startup to enterprise scale.

The Right Conversation Can Save You Six Months.

Whether you’re navigating AI adoption, modernizing legacy systems, or scaling a product - we start by listening. No pitch deck. No template. A real conversation.