Deep Links With Authentication in React Navigation

Deep links enable you to link directly to a specific screen in your app from external sources, such as social media, emails, and notifications. They are great for improving user engagement and providing a seamless user experience.

Things get a little tricky if your app requires users to log in, though. If the user is not already logged in when opening the deep link, they will be directed to the login screen instead. To ensure a smooth user experience, after they successfully log in, it would be ideal to redirect them to the appropriate screen they originally intended to visit.

However, if you have set up deep links with React Navigation, you may have noticed that this is not handled out of the box. In this post, we'll go through a few approaches to implement this flow in older versions of React Navigation, and look at a new API that makes it much easier.

What we'll cover

In this post, we'll use React Navigation's static configuration API with automatic deep links for a simpler setup; however, the concepts can be applied to custom linking configurations as well.

For our example, let's say we have an app with the following screens:

  • Accessible without login:
    • SignIn
  • Accessible after login:
    • Home
    • Profile

What we'd like to achieve:

  • If the user opens a deep link to /profile while not logged in, they should be taken to the SignIn screen.
  • After successfully logging in, they should be redirected to the Profile screen.
  • After logging out and logging back in, if there was no deep link, they should be taken to the Home screen.

This should also work regardless of whether the app was started from a cold state (not running in the background) or was already running in the background.

You can see our desired behavior in the demo video:

In the demo, you can see that the deep link to the profile was not handled before login due to conditional screens, resulting in a warning. Still, it gets handled after login and shows the Profile screen.

Our navigator will be a stack navigator with two groups: authenticated and unauthenticated, which will conditionally render screens based on the user's authentication status using the if property.

const RootStack = createNativeStackNavigator({
  groups: {
    authenticated: {
      if: useIsSignedIn,
      screens: {
        Home: {
          screen: Home,
        },
        Profile: {
          screen: Profile,
        },
      },
    },
    unauthenticated: {
      if: () => !useIsSignedIn(),
      screens: {
        SignIn: {
          screen: SignIn,
        },
      },
    },
  },
});

Then, we use the navigator with automatic deep links enabled:

const Navigation = createStaticNavigation(RootStack);

export function App() {
  return (
    <Navigation
      linking={{
        enabled: "auto",
        prefixes: [createURL("/")],
      }}
    />
  );
}

If you are not familiar with automatic deep links, they automatically generate deep link paths based on screen names, e.g. ,profile to go to the Profile screen.  You can read more about it in React Navigation docs.

Manual approaches

In older versions of React Navigation, there is no built-in way to handle this flow, so we need to implement it manually. Here are two main approaches:

Approach 1: Remounting the navigation container

React Navigation always handles deep links on mount. So we can take advantage of this behavior to handle deep links after login by remounting the navigation container. This can be done by using a key prop on the Navigation component (or NavigationContainer in the dynamic API):

export function App() {
  const isSignedIn = useIsSignedIn();
  const lastDeepLink = useLastDeepLink();

  return (
    <Navigation
      key={isSignedIn ? "signed-in" : "signed-out"}
      linking={{
        enabled: "auto",
        prefixes: [createURL("/")],
        getInitialURL: () => lastDeepLink,
      }}
    />
  );
}

Here, we change the key prop based on the authentication status. This forces React to unmount and remount the navigation container when the user logs in or out.

The useLastDeepLink hook is a custom hook that keeps track of the last deep link URL opened, and clears it after the user logs in:

function useLastDeepLink() {
  const isSignedIn = useIsSignedIn();

  // Keep track of the incoming deep links to handle after logging in
  const [deepLink, setDeepLink] = useState(() =>
    isSignedIn ? null : Linking.getInitialURL()
  );

  useEffect(() => {
    // Clear initial URL after logging in
    // This prevents handling the same deep link again after logging out and logging back in
    if (isSignedIn) {
      setDeepLink(null);
    }

    // Store incoming deep links if not authenticated
    // They will be handled after logging in
    const subscription = Linking.addEventListener("url", ({ url }) => {
      if (!isSignedIn) {
        setDeepLink(url);
      }
    });

    return () => {
      subscription.remove();
    };
  }, [isSignedIn]);

  return deepLink;
}

We need to manually track the last deep link and clear it after login, otherwise, it will lead to some issues:

  • Logging out and logging back would reuse initial deep link, so we need to clear it after first login.
  • If a deep link is opened while the user is already on login screen, it won't be handled after login unless we keep track of it and pass it to the navigation container, as React Navigation only handles the initial URL on mount.

Disadvantages:

  • There is no animation between screen transitions, and depending on the navigator, it may flicker.
  • The entire navigation state is reset, it's not possible to do it only in a nested navigator.
  • It needs manual tracking of deep links to avoid issues.

Another variant of this approach is to show a login screen first without rendering the navigation container at all. However, it still has the same disadvantages as above and doesn't work for more complex flows where you may want to navigate to other screens from the login screen (e.g., sign up, forgot password, etc.).

As an alternative, we can track incoming deep links and, when the user logs in, handle the deep link ourselves and navigate to the corresponding screen.

To keep track of incoming deep links, we can use the same useLastDeepLink hook used in the previous section. Then, we'd parse the deep link URL and navigate to the appropriate screen after login:

const linking = {
  enabled: 'auto',
  prefixes: [createURL('/')],
};

// Get the deep link configuration for our screens
// This is used by React Navigation to parse the incoming URLs internally
// But since we are handling deep links manually, we need this for parsing
const linkingPathConfig = createPathConfigForStaticNavigation(
  RootStack,
  // Pass `linking.options` if present
  undefined,
  true,
);

export function App() {
  const ref = useNavigationContainerRef();
  const authenticated = useAuth((state) => state.authenticated);

  const lastDeepLink = useLastDeepLink();

  // Handle the stored deep link after logging in
  useEffect(() => {
    const handleDeepLink = async () => {
      const url =
        typeof lastDeepLink === 'string' ? lastDeepLink : await lastDeepLink;

      if (ref.isReady() && url && linkingPathConfig) {
        // Strip the prefix from the URL
        const path = linking.prefixes.reduce((acc, prefix) => {
          if (acc.startsWith(prefix)) {
            return acc.slice(prefix.length);
          }

          return acc;
        }, url);

        // Get the navigation state from the path
        const state = getStateFromPath(path, { screens: linkingPathConfig });

        // If we have a valid state, get the action and dispatch it
        // This will navigate to the intended screen
        if (state) {
          const action = getActionFromState(state);

          ref.dispatch(action!);
        }
      }
    };

    if (authenticated && lastDeepLink) {
      handleDeepLink();
    }
  }, [authenticated, ref]);

  return <Navigation ref={ref} linking={linking} />;
}

Here, we check if there is a stored deep link after the user logs in. If there is, we parse the URL to get the navigation state, then derive the navigation action from that state and dispatch it to navigate to the intended screen.

After stripping the prefix from the deep link URL, we parse it with getStateFromPath from @react-navigation/native to match how React Navigation parses it. It takes our linking options as an argument, which we can also generate with createPathConfigForStaticNavigation when using the static API.

This is similar to how React Navigation handles deep links internally, but we are doing it manually after login.

Disadvantages:

  • Requires more manual work and things to keep track of.
  • There will be a separate navigation after login, which may not feel as smooth.
  • The initial route will always be in the stack, which may not be desirable in some cases.

Built-in API in React Navigation 7

React Navigation 7 adds a new API to handle this use case more seamlessly. If you are on the latest version of all @react-navigation packages, you can enable it by setting UNSTABLE_routeNamesChangeBehavior to 'lastUnhandled' in the navigator that handles authentication state changes:

const RootStack = createNativeStackNavigator({
  UNSTABLE_routeNamesChangeBehavior: 'lastUnhandled',

  // ... rest of the navigator config
});

To understand what this does, let's review what happens when the authentication state changes:

  • When the user logs in, the list of available screens (names of routes) changes based on the new authentication state.
  • If none of the previously rendered screens are available anymore, React Navigation shows the first screen in the new list of available screens.

The UNSTABLE_routeNamesChangeBehavior option essentially lets us customize this behavior. By default, it's set to firstMatch, which means it will show the first screen in the new list of available screens.

By setting it to lastUnhandled, we tell React Navigation to remember any unhandled actions (like deep links) that were attempted before the authentication state changed. So when the user logs in, if there was a deep link that couldn't be handled before, React Navigation will retry handling that deep link after the authentication state changes, navigating the user to the intended screen.

This approach has several advantages:

  • It's built-in and requires minimal code, we only need to set a single option in the navigator.
  • The navigation is seamless, with proper animations between screens.
  • It works well with nested navigators, only the navigator where the authentication state changes needs to have this option set.

However, as the name suggests, this API is marked as unstable, which means it may change in future releases depending on user feedback and further testing. So it is the perfect time to try it out and provide feedback. You can post your thoughts and feedback in the React Navigation GitHub discussions.

Demo app

You can find a demo app implementing the approaches discussed in this post in this GitHub repository.

The repository has three branches, each implementing a different approach:

The app can be run with Expo Go.

To test the deep links, you can use the uri-scheme utility by Expo, for example:

npx uri-scheme open 'exp://192.168.0.31:8081/--/profile' --ios

In this case, the exp://192.168.0.31:8081 prefix refers to the server URL of the Expo Go app running on your machine, and the deep link path is /profile.

Conclusion

These approaches show how handling deep links is challenging when authentication is involved, with many gotchas and complexity when trying to handle them manually. I tried to solve these problems with the new UNSTABLE_routeNamesChangeBehavior option in React Navigation 7, and tried to keep it simple to use without the need for complex code and workarounds.

If your app relies on deep links, be sure to give this a try, and don’t forget to provide feedback.

Insights

Learn more about React Native

cover

Aug 4Podcast

VisionCamera V5 With Marc Rousavy

Marc Rousavy joins Mike Grabowski to unpack VisionCamera v5, Nitro Modules, the constraints API, migration work, and what changed for React Native camera apps.

cover

Jul 1Podcast

Redraw for Effects Beyond Skia

William Candillon talks about where Redraw fits next to Skia, from physics-based rendering to camera effects, AR, and new UI patterns.

cover

Jun 29Article

Less Boilerplate, More Type Safety: React Navigation 8's Typed Hooks

React Navigation 8 improves typed hooks with screen-aware types, less boilerplate, and fewer unsafe casts in shared UI and nested navigators.

cover

Apr 30Live Stream

Build a React Native App With the TanStack Stack

Join Callstack’s live stream on building a React Native app from the ground up using the TanStack toolbelt.

cover

Apr 28Article

React Native production lessons from React Universe Meetup x Zalando

Lessons from React Universe Meetup x Zalando on React Native in production, covering brownfield performance, feed video, native API access, and on-device AI.

cover

Apr 23Talk

Shipping iOS Live Activities with React: The Voltra Story

How Voltra enables Live Activities and Dynamic Island views using React without writing Swift. A deep dive into iOS constraints and architecture.

cover

Apr 16Live Stream

React Native 0.85: Animation Backend and Key Updates

A recap of React Native 0.85, covering the new animation backend, DevTools improvements, and ecosystem takeaways.

cover

Mar 5Article

Keeping Screens Visible With <Activity mode="hidden">

A cross‑platform workaround to keep screens visible while <Activity mode="hidden"> unmounts effects on inactive screens by setting mode="hidden".

cover

Feb 25Article

React Native Monorepo With pnpm Workspaces

Set up a React Native monorepo with pnpm workspaces for mobile and Next.js web, including Metro fixes, hoisted node-linker, and shared packages.

cover

Feb 18Article

A Practical Guide to React Native Monorepo With Yarn Workspaces

Learn how to set up a React Native monorepo with Yarn workspaces for mobile and web, including Metro, Gradle, and shared packages.

cover

Feb 17Podcast

From Teddy Bears to Voice Agents: Kraen Hansen on Voice AI, Local-First & App Security

What happens when you combine voice agents and teddy bears? A hands-on look at local-first systems, voice AI, and security lessons from poking real apps.

cover

Jan 28Article

Bringing CSS Clipping to React Native

A deep look at how a single CSS feature moves through React Native’s New Architecture, from JavaScript styles to native rendering on iOS and Android.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.