This article is published in English.
Routing Push Notification Taps Through Deep Links in React Native
Set up universal links and app links, wire Pusher Beams into a React Native app, and map web URLs to native screens so a notification tap opens the right place.
A push notification is only useful if tapping it lands the user on the screen it talks about. Picture a React Native app for international students choosing colleges: email goes unread and SMS is unreliable across countries, so push has to work when a recruiter writes. Here you will configure deep linking on both platforms, attach a website URL to each Pusher Beams notification, and translate that URL into a React Navigation route, testing each layer along the way.
Why deep links carry the notification's destination
At the time this setup was built, the Pusher packages used here could not forward a notification's custom payload to the JavaScript side of a React Native app on Android. The simplest workaround, along the lines of a pull request to the Android SDK, is to lean on deep linking: the Android notification carries a regular website link, the tap is treated like a click on that link, and the app's existing deep-link handling decides which screen to open.
This may have changed since, so check the current Beams SDKs first. The routing layer below is useful either way, since it also serves links from email, SMS or chat.
A quick recap of deep linking
Deep linking lets the app claim links to your own website and open them natively. A link such as https://test.com/message/abc should open the app directly on message abc instead of launching a browser. iOS calls these universal links, Android calls them app links, and both require your domain to publish a file proving it trusts the app.
Setting up deep linking on both platforms
Publish the verification files under .well-known
Create a /.well-known/ directory at the root of your website. For Android, add /.well-known/assetlinks.json, which declares that your app may handle all URLs on the domain. Replace the placeholders with your application's package name and the SHA-256 fingerprint of the certificate that signs it:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "package_name",
"sha256_cert_fingerprints":
["package_cert_fingerprint"]
}
}]
If Google Play re-signs your releases, use the Play signing key's fingerprint, not your upload key's, or verification fails only in production.
For iOS, add a file named /.well-known/apple-app-site-association (no extension). It lists your app identifier, which combines the Team ID and bundle ID, and the URL paths the app should intercept:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "appId",
"paths": [ "/paths-you-want-to-support", "/messenger"]
}
]
}
}
Serve both files over HTTPS from the exact domain, without redirects. This is the classic applinks layout; Apple has since added a newer format, so check its current documentation.
Enable Associated Domains on iOS
iOS also needs an entitlement that tells it which domains belong to the app. In Xcode, open Capabilities, switch on Associated Domains, and add each domain you want to connect, typically as applinks:yourdomain.com.
Declare intent filters on Android
On Android, deep links are declared with intent filters in AndroidManifest.xml. To capture /messenger/* links, the main activity needs an intent filter for the VIEW action with the DEFAULT and BROWSABLE categories, and a data element that specifies the https scheme, your host, and a path prefix of /messenger. Adding android:autoVerify="true" tells the system to check assetlinks.json so the app can open these links without showing a chooser dialog.
Listen for links in the root component
The root component of the app should do two things: read the URL that launched the app from a cold start, and listen for URLs that arrive while it is already running. React Native's Linking module provides both, through getInitialURL() and a url event listener. Both should call one handler, which for now can just log the URL:
const handleDeepLink = (url: string) => console.log(url);
Test links before adding notifications
Verify deep linking on its own before layering push on top of it. An easy method is to send a link to yourself through a messaging app such as Slack on each test device and tap it. If the configuration is right, the link opens your app rather than the browser, and the logged URL matches the one you tapped. Isolating this layer means a misrouted notification later can only be a push problem.
Adding Pusher Beams to the app
Prerequisites and the community bridge
You need a Pusher Beams account, and you must complete its setup steps for Apple's push service and for Google Firebase.
Be aware that Pusher did not offer an official React Native package when this integration was written. The bridge used here is the community react-native-pusher-push-notifications package, which connects the native Beams SDKs to JavaScript and is not supported by Pusher. It needed small adjustments to work in this setup. On Android, the key change was a fork of Pusher's push-notifications-android SDK that turns a notification tap into a deep-link open. An unofficial bridge plus a forked SDK is a maintenance commitment: pin versions and revisit the choice when official SDKs change.
Install the Swift SDK on iOS
The iOS side depends on Pusher's Swift SDK, installed here with Carthage. Add the following line to /ios/Cartfile, then run carthage bootstrap to fetch and build it:
github "pusher/push-notifications-swift" ~> 1.3.0
That version was current for this integration; newer projects will likely use a more recent SDK and possibly another dependency manager.
Then follow the bridge package's manual installation steps for both platforms, since the native pieces are customized.
Sending a test notification
Notifications on iOS only arrive on physical devices, not on simulators, so keep a real iPhone at hand.
To send a test push, use either the Debug Console in the Pusher dashboard or an HTTP client such as Postman calling the Beams publish API. A request body with an iOS section and an Android section is enough; in this setup the iOS part sets a badge count of 5, and the Android part carries the website URL that should be opened.
When everything is wired correctly, iOS processes the notification payload itself, while Android opens the app through a View intent carrying your website URL. On both platforms, your handler should log something like https://yourdomain/messenger/abcde. On iOS, the app icon should additionally show a badge with the number 5.
Turning URLs into React Navigation routes
The final step is to replace the logging stub with real routing. A common setup uses React Router on the website and React Navigation in the React Native app. A small utility that maps web routes to native routes lets both share logic and keeps a renamed web URL from quietly breaking app navigation.
Share route constants between web and native
Both codebases define their routes as constants. On the web, a route builder returns either a concrete path or the pattern React Router matches against; on native, the equivalent is a screen name, with the conversation ID passed separately as a navigation param:
// Web:
ROUTE = {
MESSENGER_CONVERSATION: (conversationId?: string) =>
conversationId
? `/messenger/${conversationId}`
: "/messenger/:conversationId"
}// Native:
APP_STACK_ROUTE: {
MESSENGER_CONVERSATION_SCREEN:
"app_stack_routes/messenger_conversation"
}
// native then has a params object with conversationId included
With both sides expressed as constants, you can declare which web path corresponds to which native screen in a single table:
const ROUTE_MATCHES: IRouteMatches = [
{
webPath: ROUTE.MESSENGER_CONVERSATION(),
rnPath: APP_STACK_ROUTES.MESSENGER_CONVERSATION_SCREEN
}
];
Calling ROUTE.MESSENGER_CONVERSATION() with no argument returns the pattern /messenger/:conversationId, so the table stores the same pattern the website router uses. Renaming a web route updates the mapping automatically. For a refresher on how those web patterns work, see React Router basics.
Debounce incoming URLs
The handler sometimes fires more than once per tap, for example when the initial-URL check and the listener both report a link, which pushes duplicate screens. Pushing every URL through an RxJS Subject and applying debounceTime(100) collapses bursts into a single call, which then goes to processUrl:
export const handleDeepLink = (url: string): void => {
if (!url) return;
onChangeUrl$.next(url);
};
const onChangeUrl$: Subject<string> = new Subject<string>();
const urlSubscription: Observable<string> = onChangeUrl$.pipe(debounceTime(100));
urlSubscription.subscribe(processUrl);
The trade-off: two different links within 100 ms collapse into the last one, which is acceptable for notification taps.
Break the URL into parts
processUrl first splits the URL into protocol, host, path and query string using a regular expression (a tool such as RegExr helps when adjusting it). The destructuring skips the full match and the query group that still contains the ?:
const REGEX_DECONSTRUCT_URL = /^(.*?):\/\/(.*?)(\/.*?)(\?(.*))?$/;const deconstructedUrl = REGEX_DECONSTRUCT_URL.exec(url);
if (!deconstructedUrl) return;
const [originalUrl, protocol, tld, path, ignore, querystring] = deconstructedUrl;
Note that this pattern requires a path: a bare https://yourdomain without a trailing slash will not match and the function returns early. That is fine for notification links, but worth knowing if you reuse the helper elsewhere.
Match the path against the route table
With the pieces extracted, the handler only proceeds for HTTPS links on your domain, then walks through ROUTE_MATCHES until one entry accepts the path:
if (protocol === "https" && tld.includes("yourdomain")) {
for (let i = 0; i < ROUTE_MATCHES.length; i++) {
if (matchPath(ROUTE_MATCHES[i], path, querystring)) {
// loop until one matches
break;
}
}
}
The tld.includes("yourdomain") check is convenient but loose: it would also accept a host like yourdomain.attacker.example. The OS verifies universal and app links, so the risk is limited, but an exact host allow-list is a cheap improvement once other URL sources share the handler.
Inside matchPath, the webPath pattern is tested against the incoming path with path-to-regexp, the same library React Router relies on. When it matches, the extracted parameters, such as conversationId, become the params for the corresponding rnPath screen, and the app navigates there. Because this runs outside any component, a small navigation service holding the root navigator reference triggers it, as React Navigation's docs describe for navigating without the navigation prop.
Wrapping up
With these pieces in place, the app handles two jobs through one code path:
- Links to your website, whether they come from email, SMS or chat, open the matching screen in the native app.
- Push notifications reuse the same deep-link handling, so a tap takes the user straight to the relevant conversation.
What keeps this maintainable is treating the website URL as the single description of a destination, with one table converting URLs into native routes. Test each layer separately, tighten the host check, and watch the official Beams SDKs: if they forward payloads to JavaScript, you can drop the fork and keep the routing layer.