expo-linking API
Parsing URLs, listeners, and cold-start vs warm-start handling - the expo-linking cookbook for Expo SDK 57 apps that receive links from email, SMS, QR codes, and other apps.
Search across all documentation pages
Parsing URLs, listeners, and cold-start vs warm-start handling - the expo-linking cookbook for Expo SDK 57 apps that receive links from email, SMS, QR codes, and other apps.
Quick-reference recipe card - copy-paste ready.
import * as Linking from "expo-linking";
import { useEffect } from "react";
import { router } from "expo-router";
function routeFromUrl(url: string) {
const { path, queryParams } = Linking.parse(url);
const cleanPath = `/${(path ?? "").replace(/^\/+/, "")}`;
router.push({ pathname: cleanPath as never, params: queryParams ?? {} });
}
export function useDeepLinkBootstrap() {
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url) routeFromUrl(url);
});
const sub = Linking.addEventListener("url", ({ url }) => routeFromUrl(url));
return () => sub.remove();
}, []);
}When to reach for this:
maps://, mailto:) with openURL.createURL.// src/linking/useAppLinking.ts
import * as Linking from "expo-linking";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { z } from "zod";
const orderParams = z.object({
id: z.string().min(1),
ref: z.string().optional(),
});
function navigateFromUrl(url: string) {
const parsed = Linking.parse(url);
const segments = (parsed.path ?? "").split("/").filter(Boolean);
if (segments[0] === "orders" && segments[1]) {
const result = orderParams.safeParse({
id: segments[1],
ref: parsed.queryParams?.ref,
});
if (!result.success) {
router.replace("/");
return;
}
router.push({
pathname: "/orders/[id]",
params: { id: result.data.id, ref: result.data.ref ?? "" },
});
return;
}
router.replace("/");
}
type Options = {
enabled: boolean;
};
export function useAppLinking({ enabled }: Options) {
const handledInitial = useRef(false);
useEffect(() => {
if (!enabled) return;
Linking.getInitialURL().then((url) => {
if (!url || handledInitial.current) return;
handledInitial.current = true;
navigateFromUrl(url);
});
const subscription = Linking.addEventListener("url", ({ url }) => {
navigateFromUrl(url);
});
return () => subscription.remove();
}, [enabled]);
}// app/_layout.tsx
import { Stack } from "expo-router";
import { useAppLinking } from "../src/linking/useAppLinking";
import { useAuthReady } from "../src/auth/useAuthReady";
export default function RootLayout() {
const authReady = useAuthReady();
useAppLinking({ enabled: authReady });
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="orders/[id]" options={{ title: "Order" }} />
</Stack>
);
}What this demonstrates:
getInitialURL with a ref guard against double handling.addEventListener with cleanup on unmount.authReady so protected routes do not flash.router.push with dynamic pathname and params.getInitialURL reads it once JavaScript starts.url channel - same shape { url: string }.app/ - your listener runs in parallel for side effects.Linking.parse uses the WHATWG URL parser - invalid URLs return { path: null, queryParams: null }.| Scenario | App state | API | Pitfall |
|---|---|---|---|
| Cold start | Process killed | getInitialURL() | Navigating before auth/Router ready drops the URL |
| Warm start (background) | JS running | addEventListener("url") | Duplicate handler if layout remounts without cleanup |
| Warm start (foreground) | JS running | addEventListener("url") | Same URL may fire twice on some Android OEMs |
| User opened from icon | No link | getInitialURL() → null | Do not treat null as an error |
| Method / Event | Returns | Use |
|---|---|---|
Linking.parse(url) | { scheme, hostname, path, queryParams } | Inspect inbound URLs |
Linking.createURL(path, opts?) | string | Build share/referral links |
Linking.getInitialURL() | Promise<string | null> | Cold-start bootstrap |
Linking.addEventListener("url", fn) | Subscription | Warm-start listener |
Linking.openURL(url) | Promise<true> | Open external apps |
Linking.canOpenURL(url) | Promise<boolean> | Check if target app exists |
Linking.getLinkingURL() | string | null | Dev-only Expo Go URL (avoid in prod) |
import * as Linking from "expo-linking";
type ParsedLink = ReturnType<typeof Linking.parse>;
function queryParam(
parsed: ParsedLink,
key: string
): string | undefined {
const value = parsed.queryParams?.[key];
return Array.isArray(value) ? value[0] : value;
}queryParams values may be string | string[] - normalize before Zod parsing.path omits leading slash for custom schemes - normalize before concatenation.router.push typed routes when experiments.typedRoutes is enabled - invalid paths fail at compile time.useAppLinking({ enabled: authReady }) as shown above.initialRouteName explicitly.return () => subscription.remove() in useEffect.myapp:// strings - breaks across flavors and Expo Go. Fix: always Linking.createURL("orders/42").?token= in URLs lands in analytics and logs. Fix: exchange tokens server-side; use short-lived one-time codes in links.canOpenURL blocked on iOS without LSApplicationQueriesSchemes - always returns false for unlisted schemes. Fix: declare queried schemes in app.config.ts ios.infoPlist.| Alternative | Use When | Don't Use When |
|---|---|---|
| Expo Router linking only | File-based routes cover all paths | You need pre-navigation analytics or token exchange |
React Navigation linking config | Brownfield React Navigation app | Greenfield Expo Router project |
| Branch / AppsFlyer SDK | Deferred deep links + attribution | Simple custom-scheme-only app |
Manual Linking.parse in every screen | Legacy codebase | New code - centralize in one hook |
expo-linking for createURL, openURL, canOpenURL, and custom pre-navigation logic._layout.tsx or an auth provider.const url = "maps://?q=Coffee+Shop";
if (await Linking.canOpenURL(url)) {
await Linking.openURL(url);
}LSApplicationQueriesSchemes on iOS.parse and createURL work on web with http/https origins.getInitialURL returns the page URL on first load - guard with Platform.OS !== "web" if sharing code.?tag=a&tag=b yields queryParams.tag as string[].path only - strip token, email, and code query keys before analytics.https:// links trigger the same event once the app opens.jest.spyOn(Linking, "getInitialURL").mockResolvedValue("shopapp://orders/1");addEventListener to invoke the callback manually in integration tests.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026