Mobile Apps

Deep Links That Don't Blow Your Navigation Stack

Handling deep and universal links in React Native and Flutter without corrupting the back stack, losing state, or triggering duplicate screens.

Mohammed Saqib10 min read
Close-up of a hand holding a smartphone with AI applications on screen.
Photo by Solen Feyissa on Pexels · Pexels License

Deep links are supposed to drop users exactly where they need to be. Too often they derail the navigation stack instead: a notification opens a profile screen, then pressing back skips the feed entirely and exits the app, or worse, duplicates the same screen on top of itself. The root cause is almost always treating a deep link as a simple push when it should be treated as a navigation state reset that respects existing routes.

The most common failure pattern: your app receives a deep link, and the default behaviour in both React Navigation and go_router is to push a new screen onto the current stack. If the user was already on a similar screen (or even the same screen with different params), you get duplicates. If the navigator was initialised fresh during a cold start (no saved state), the back stack is either nonexistent or incorrect, so the hardware back button behaves unexpectedly.

Framework defaults are designed for user-initiated navigation, not incoming links. React Navigation's linking config without a custom getStateFromPath pushes the target route as a new entry. go_router's initialLocation or pushReplacement behaviour depends on how you parse the URI — but many examples show context.push() on link reception, which adds to the stack rather than replacing. During cold starts, if you rely on Linking.getInitialURL() and then initialise the navigator with that path, you might lose any persisted state that the user expected to find after a restart. I've written about Cutting cold start time in React Native apps where similar state management considerations apply.

State loss also happens when the navigator is re-created on link reception, especially if the app was backgrounded and the system killed its process. A deep link should restore the app to the correct screen without discarding the state of other parts of the app (e.g., a multi-step form with unsaved input).

React Native: React Navigation Linking Config

React Navigation's linking prop accepts a configuration object where you can define how paths map to navigation state. The critical piece is getStateFromPath. By default, React Navigation converts a path like /profile/42 into a state object with a single route. If your navigator already has a stack with Home and Profile, the default behaviour will push a second Profile on top.

// navigation/LinkingConfiguration.js
import { getStateFromPath as defaultGetState } from '@react-navigation/native';
 
const linking = {
  prefixes: ['https://myapp.com', 'myapp://'],
  config: {
    screens: {
      Home: '',
      Profile: 'profile/:userId',
      Settings: 'settings',
    },
  },
  getStateFromPath(path, config) {
    const defaultState = defaultGetState(path, config);
    if (!defaultState) return defaultState;
    if (defaultState.routes[0].name === 'Profile') {
      return {
        routes: [
          { name: 'Home' },
          { name: 'Profile', params: defaultState.routes[0].params },
        ],
        index: 1,
      };
    }
    return defaultState;
  },
};

For cold starts, call Linking.getInitialURL() before your app mounts and pass that URL into the linking config via initialRouteName or by setting the initial navigation state directly. For warm starts (app already open), subscribe with Linking.addEventListener('url', handler). The handler should call navigation.reset(state) rather than navigation.navigate if the target represents a new logical destination (e.g., a notification). Using reset clears the current stack and replaces it with the correct one, eliminating duplicates by design. This approach integrates well with Adopting React Native New Architecture: Fabric and TurboModules where navigation performance considerations differ.

go_router routes deep links by matching the URI path against your route configuration. The default behaviour treats an incoming link as a navigation to a new location, which will add to the stack if push is used. To prevent duplicates, use the redirect property of GoRouter to intercept navigation and decide whether to replace rather than push.

// main.dart
final goRouter = GoRouter(
  initialLocation: '/home',
  redirect: (context, state) {
    if (state.uri.toString().startsWith('https://myapp.com/')) {
      final currentLocation = context.matchedLocation;
      final targetLocation = state.uri.toString().replaceFirst('https://myapp.com', '');
      if (currentLocation == targetLocation) {
        return null;
      }
      return targetLocation;
    }
    return null;
  },
  routes: [
    GoRoute(path: '/', redirect: (_) => '/home'),
    GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
    GoRoute(path: '/profile/:userId', builder: (_, state) => ProfileScreen(userId: state.pathParameters['userId']!)),
  ],
);

With the app_links plugin (or uni_links), you parse the incoming URI and call context.go() which replaces the current location, preserving only one instance of the screen. Avoid context.push() for deep links because it stacks a new screen on top.

For Universal Links (iOS) and App Links (Android), verification is non-negotiable. If the Associated Domain file (on iOS) or assetlinks.json (on Android) are misconfigured or not served over HTTPS, the link falls back to the browser and your app never sees it. Apple's validation tool (iOS 13+) and Google's Digital Asset Links API are essential for confirming that the app is authorised to handle the domain. If you're deciding between frameworks, Flutter versus React Native: Choosing by Team Shape covers team composition factors that affect deep link implementation complexity.

Preventing Duplicate Screens and Lost Back Stack

A general principle: when a deep link arrives, check if the target route already exists in the current stack. If it does, pop back to it instead of pushing a new instance. This keeps the back stack clean.

React Navigation: use CommonActions.navigate with undefined key for the target route. If a screen with that name and matching params already exists, React Navigation will pop back to it instead of pushing another copy. Alternatively, navigation.reset() gives you full control: build a state object with only the routes you want, and set index to the deepest one.

import { CommonActions } from '@react-navigation/native';
 
function handleDeepLink(url, navigation) {
  const { userId } = parseUrl(url);
  navigation.dispatch(
    CommonActions.navigate('Profile', { userId })
  );
}

This works because navigate with a route name that already exists (same key or same name+params) will pop back to it, not duplicate.

Flutter: inside the deep link handler, check context.matchedLocation against the target path. If they match, do nothing. If they don't, use context.go() which replaces the entire stack with the target route, clearing forward history. This is the most reliable way to avoid duplicates in go_router.

void handleDeepLink(String uri, BuildContext context) {
  final path = Uri.parse(uri).path;
  final current = GoRouterState.of(context).matchedLocation;
  if (current != path) {
    context.go(path);
  }
}
Feature React Navigation (Linking) go_router
Default deep link behaviour Pushes new screen Pushes if using context.push()
Prevent duplicates CommonActions.navigate with matching params, or navigation.reset context.go() (replaces) + redirect guard
Multi-stack support getStateFromPath returns full state tree; linking.screens maps nested navigators ShellRoute or flattened routes
Cold start handling Linking.getInitialURL() + initial state initialLocation or redirect from root
iOS Universal Link / Android App Links Built-in with config Requires app_links plugin and verification

Bottom tabs with nested stacks are the hardest case. A deep link like /chats/123/messages should open the Chats tab, navigate to the conversation stack, and show messages — all while preserving the state of the other tab (e.g., Home with its scroll position).

React Navigation: define the linking config with a screens map that reflects your navigation hierarchy. Each key in screens can be a path, and you can nest configs for navigators inside the same structure. The getStateFromPath function must return a state object that includes every level of navigation — tabs and stacks — with the correct index for each.

const linking = {
  prefixes: ['https://myapp.com'],
  config: {
    screens: {
      MainTabs: {
        screens: {
          HomeTab: 'home',
          ChatsTab: {
            path: 'chats',
            screens: {
              ChatList: '',
              Chat: ':chatId',
              Messages: ':chatId/messages',
            },
          },
        },
      },
    },
  },
};

When a URL like /chats/123/messages comes in, React Navigation automatically computes the state: { routes: [ { name: 'MainTabs', state: { routes: [ { name: 'ChatsTab', state: { routes: [ChatList, Chat(123), Messages], index: 2 } } ], index: 1 } } ], index: 0 }. The key is that each nested navigator gets its own state field, preserving the structure of the tabs and stacks. You should not re-create the tab navigator; the linking config maps directly into it. For apps that also need Offline-First Sync with SQLite and REST in React Native, preserving navigation state across background fetches becomes even more important.

Flutter: go_router's ShellRoute is the intended way to implement multi-tab layouts. Deep links navigate to specific sub-routes without rebuilding the shell (the tab bar). Avoid placing the entire tab navigator inside a single route — instead, use ShellRoute to wrap the tab structure and define sub-routes for each tab's content.

final goRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(path: '/home', builder: (_, __) => HomeScreen()),
        GoRoute(
          path: '/chats',
          builder: (_, __) => ChatListScreen(),
          routes: [
            GoRoute(path: ':chatId', builder: (_, state) => ChatScreen(chatId: state.pathParameters['chatId']!)),
            GoRoute(path: ':chatId/messages', builder: (_, state) => MessagesScreen(chatId: state.pathParameters['chatId']!)),
          ],
        ),
      ],
    ),
  ],
);

When a deep link hits /chats/123/messages, go_router preserves the ShellRoute builder (the tab bar) and only updates the inner navigator to show MessagesScreen. The other tab's state remains intact because Flutter retains the navigator's page stack. This approach is similar to how Expo EAS Build Profiles That Keep CI Reproducible preserve environment configuration across builds.

Failure Modes and Debugging Tips

Duplicate deep link invocations on Android: Linking.addEventListener fires once, but onNewIntent in MainActivity may also trigger the same link. The result: two navigation actions that can leave you on the wrong screen. The fix is a timestamp guard. Store the last deep link URL and timestamp; if a new link arrives with the same URL within 200ms, ignore it. Alternatively, use the react-native-avoid-duplicate-links package.

iOS universal links failing silently: The most common culprit is the apple-app-site-association file not being served with Content-Type: application/json over HTTPS. Apple also requires the file to be at the root of your domain (no redirects). Use the Apple App Site Association (AASA) validator to test.

Log navigation state before and after link handling. In React Navigation, you can add a useEffect that logs navigation.getState(). In Flutter, implement a custom NavigatorObserver that prints the route stack after any navigation event. A middleware that warns when the route list length grows unexpectedly (e.g., more than 2 screens for a simple profile link) catches stack corruption early. For broader debugging patterns, Designing GitHub Actions That Fail Fast and Explain Why has relevant principles for automated validation.

Integration tests should simulate incoming URLs and assert that the navigation state (routes, index, params) matches expectations. Do not just test that a screen appeared — test the entire stack.

React Native: with @testing-library/react-native and mocked Linking, you can simulate a URL event and then inspect the navigation state.

import { render } from '@testing-library/react-native';
import { Linking } from 'react-native';
import App from '../App';
 
jest.mock('react-native/Libraries/Linking/Linking', () => ({
  getInitialURL: jest.fn().mockResolvedValue('https://myapp.com/profile/42'),
  addEventListener: jest.fn(),
}));
 
it('opens profile with correct stack', async () => {
  const { getByTestId } = render(<App />);
  await screen.findByTestId('profile-screen');
  expect(screen.getByTestId('profile-screen')).toBeTruthy();
});

Flutter: use NavigatorObserver in widget tests. Simulate a deep link by pushing a URI into the app_links stream manually and verify that go_router calls context.go with the correct path.

testWidgets('deep link opens profile without duplicating home', (tester) async {
  await tester.pumpWidget(MyApp());
  final uri = Uri.parse('https://myapp.com/profile/42');
  await tester.pumpAndSettle();
  expect(find.text('Profile: 42'), findsOneWidget);
});

Testing stack depth directly often requires exposing a mockable navigator key or observer. The key is that the test fails if the stack has duplicates or missing screens. Similar testing rigour applies when Evaluating LLM Output Without a Golden Dataset — both require deterministic assertions about state.

Key takeaways

  • Always use getStateFromPath (React Navigation) or context.go() (Flutter) for deep links that represent new logical destinations; avoid push which creates duplicates and corrupts the back stack.
  • For multi-tab apps, use the framework's declarative routing config (linking.screens or ShellRoute) instead of imperatively managing tab state on link reception.
  • Log navigation state before and after handling a deep link during development; a sudden increase in route count is a red flag.
  • Validate Universal/App Links with Apple's AASA checker and Google's Digital Asset Links API; a misconfiguration means the link never reaches your app.
  • Test deep link integrity by simulating incoming URLs and asserting the exact route structure (depth, names, params), not just screen visibility.

Frequently asked questions

How do I prevent a deep link from pushing the same screen twice?
Check if the target screen already exists in the navigation stack. In React Navigation, use `navigation.reset()` or `CommonActions.navigate()` with the route's key to avoid duplication. In Flutter's go_router, call `context.go()` instead of `context.push()` and use redirects to skip redundant routes.
Why does my universal link open the app but show a blank screen?
This often happens when the deep link route path doesn't match any configured route in the navigator. Verify your route pattern in React Navigation's `linking.config` or go_router's route list. Also ensure the URL is decoded properly and query parameters are parsed as expected.
Do deep links work correctly when the app is killed vs backgrounded?
Yes, but the handling differs. On cold start, you must read the initial link via `Linking.getInitialURL()` (React Native) or `getInitialUri()` (Flutter) before the navigator mounts. On warm start, subscribe to link events. Both cases should apply the same navigation logic to avoid state corruption.
#react-native#flutter#deep-links#navigation#mobile
Share

Keep reading