Skip to main content

Expo Router: Handling Nested Deep Links with Dynamic Segments

 One of the most frustrating moments in React Native development is perfecting your navigation logic, only to have it break completely when accessed via a deep link.

The scenario is all too common: You have a standard navigation flow that works perfectly within the app. You click a user, you go to their details, then to their settings. The path looks like /user/123/settings.

However, when you trigger myapp://user/123/settings externally, one of two things happens:

  1. You get a 404 Unmatched Route screen.
  2. The screen renders, but your dynamic ID (123) comes back as undefined, crashing your API calls.

If you are struggling with useLocalSearchParams returning empty objects during deep linking or navigation state hydration issues in deeply nested stacks, this guide is the definitive fix.

The Root Cause: Parameter Scoping and Hydration

To fix this, you must understand how Expo Router (built on top of React Navigation) handles URL parsing versus internal state.

When you navigate internally (e.g., <Link href="/user/123/settings" />), React Navigation constructs a state object that retains the context of the navigation tree. The parameters are passed explicitly from parent to child.

However, deep linking is destructive.

When a deep link hits your app:

  1. The OS wakes up your Javascript bundle.
  2. Expo Router parses the URL string (/user/123/settings) into a set of segments.
  3. It attempts to mount the component tree matching those segments simultaneously.

The failure usually occurs because of Parameter Scoping.

In Expo Router, dynamic segments (like [id]) belong to the Folder, not necessarily the Leaf Screen. If you are in settings.tsx and ask for useLocalSearchParams(), you are asking for parameters associated specifically with the settings route. But id belongs to the parent [id] route.

While internal navigation often "leaks" these params down for convenience, deep linking is stricter. If the route hasn't fully hydrated the parent context, your child component renders before the id is available in the local scope.

The Fix: Type-Safe Global Search Parameters

The robust solution involves two changes: ensuring your file structure supports layout injection and utilizing useGlobalSearchParams for nested retrieval during the initial deep-link render.

1. The Directory Structure

Ensure your file system explicitly defines the dynamic segment as a layout context. We are going to build a route for user/[id]/settings.

/app
  ├── _layout.tsx           <-- Root Stack
  └── /user
      └── /[id]             <-- Dynamic Segment Folder
          ├── _layout.tsx   <-- Crucial: Handles the ID context
          ├── index.tsx     <-- /user/123
          └── settings.tsx  <-- /user/123/settings

2. Configuration (app.json)

First, ensure your scheme is registered. Without this, the OS cannot direct the myapp:// protocol to your bundle.

{
  "expo": {
    "scheme": "myapp",
    "plugins": [
      "expo-router"
    ]
    // ... rest of config
  }
}

3. The Implementation

Here is the complete, type-safe implementation.

The Dynamic Layout (app/user/[id]/_layout.tsx)

This file is responsible for capturing the id segment. It acts as the "Gateway" for any route nested under [id].

import { Stack, useLocalSearchParams } from 'expo-router';

export default function UserLayout() {
  // We capture the ID here to ensure the segment is valid
  // before rendering children.
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <Stack>
      <Stack.Screen 
        name="index" 
        options={{ title: `User ${id} Overview` }} 
      />
      <Stack.Screen 
        name="settings" 
        options={{ title: 'User Settings' }} 
      />
    </Stack>
  );
}

The Nested Screen (app/user/[id]/settings.tsx)

This is where the fix is applied. We stop relying on useLocalSearchParams for parent data and switch to useGlobalSearchParams.

import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useGlobalSearchParams, Stack } from 'expo-router';
import { useEffect, useState } from 'react';

export default function UserSettings() {
  // CRITICAL: useGlobalSearchParams pulls from the entire URL path.
  // This guarantees access to [id] even if this component is the
  // first screen mounted via Deep Link.
  const glob = useGlobalSearchParams();
  
  // Explicitly cast or validate the ID. 
  // During deep link hydration, this might be undefined for one render cycle.
  const userId = glob.id as string | undefined;

  const [data, setData] = useState<string | null>(null);

  useEffect(() => {
    if (!userId) return;

    // Simulate fetching data based on the dynamic ID
    console.log(`Fetching settings for user: ${userId}`);
    
    const timer = setTimeout(() => {
      setData(`Settings loaded for User ${userId}`);
    }, 500);

    return () => clearTimeout(timer);
  }, [userId]);

  if (!userId) {
    // Graceful fallback during hydration
    return (
      <View style={styles.container}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      {/* Dynamic Header Injection */}
      <Stack.Screen options={{ title: `Settings: ${userId}` }} />
      
      <Text style={styles.title}>User ID: {userId}</Text>
      <Text style={styles.data}>{data || "Loading..."}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#fff',
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 10,
  },
  data: {
    fontSize: 16,
    color: '#666',
  }
});

Deep Dive: Why useGlobalSearchParams Wins

You might wonder why we don't fix the routing configuration itself. The reality is that useLocalSearchParams is designed for isolation.

When you are inside settings.tsx, the local segment is technically just settings. The [id] belongs to the parent Layout.

When you navigate internally using Expo's <Link>, the router passes the params down as props (React Navigation behavior). However, during a deep link cold start, the router mounts the stack from the top down rapidly. There are instances where the child component mounts before the parent layout has fully propagated the "local" params down the tree.

useGlobalSearchParams bypasses the React context tree propagation and looks directly at the parsed URL string state. This makes it the single source of truth for deep linking scenarios involving nested dynamic IDs.

Edge Case: Handling Auth Guards

A common "What If" scenario: What if the deep link points to /user/123/settings, but the user isn't logged in?

You should place your protection logic in the highest order _layout.tsx possible, typically using a useEffect and useSegments.

// app/_layout.tsx (Simplified Auth Guard)
import { useEffect } from 'react';
import { Slot, useRouter, useSegments } from 'expo-router';

// Mock Auth Hook
const useAuth = () => ({ isAuthenticated: false }); 

export default function RootLayout() {
  const { isAuthenticated } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    const inAuthGroup = segments[0] === '(auth)';
    
    if (!isAuthenticated && !inAuthGroup) {
      // Redirect to sign-in, preserving the intended destination
      // usually via a query param or global state
      router.replace('/sign-in');
    }
  }, [isAuthenticated, segments]);

  return <Slot />;
}

Note: When testing deep links with Auth Guards, ensure your logic handles the redirect speed. If the redirect happens too slowly, the deep link target might mount, try to fetch data with an invalid token, and crash before the redirect occurs.

Verification

To verify your deep link works without deploying, use the Expo CLI commands.

  1. Start your server: npx expo start
  2. In a separate terminal, run:
# Android
npx uri-scheme open "myapp://user/999/settings" --android

# iOS
npx uri-scheme open "myapp://user/999/settings" --ios

If implemented correctly, the app should launch, navigate immediately to the settings screen, and display "User ID: 999" without throwing undefined errors.

Conclusion

Deep linking in React Native often reveals the cracks in an application's state management. By treating URL parameters as a global state rather than relying on component-tree inheritance, you ensure that your application is resilient to cold starts and direct external navigation.

Prioritize strict typing with TypeScript and prefer useGlobalSearchParams when accessing IDs required for API calls in leaf nodes. This ensures your users land exactly where they expect, every time.