Progressive Web Apps sit between websites and native apps. They use standard web URLs, support offline access through service workers, and can be installed to the home screen. Deep linking in PWAs is straightforward on the web side (URLs just work), but bridging PWA content to native apps requires careful handling.
For Android-specific PWA linking, see App Links for Progressive Web Apps on Android. For the cross-platform overview, see cross-platform deep linking guide for 2026.
How PWA Deep Linking Differs
Standard deep linking connects a URL to a native app screen. PWAs introduce a third state: the URL can open in the browser, in the installed PWA, or in a native app. The decision depends on what the user has installed and their platform.
| User State | iOS Behavior | Android Behavior |
|---|---|---|
| Has native app | Universal Link opens app | App Link opens app |
| Has PWA installed (no native app) | Opens in Safari (iOS doesn't open installed PWAs from links) | Can open installed PWA or browser |
| Has neither | Opens in browser | Opens in browser |
The key difference: iOS does not open installed PWAs from tapped links. Even if the user has added your PWA to their home screen, tapping a link opens Safari. Android is more flexible with its TWA (Trusted Web Activity) support.
Web App Manifest
The web app manifest defines how your PWA behaves when installed. For deep linking, the relevant fields are start_url, scope, and display:
{
"name": "Your App",
"short_name": "YourApp",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#1a1a2e",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
scope and Deep Links
The scope field determines which URLs the PWA handles. URLs outside the scope open in the browser instead of the PWA:
{
"scope": "/app/",
"start_url": "/app/"
}
With this scope, /app/products/123 opens in the PWA but /blog/article-1 opens in the browser. For deep linking, set the scope to cover all paths you want the PWA to handle.
Routing in PWAs
Since PWAs are web apps, URL routing uses standard client-side routing:
// React example with React Router
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/:productId" element={<ProductDetail />} />
<Route path="/offers/:offerId" element={<OfferDetail />} />
<Route path="/referral/:referrerId" element={<Referral />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
When a user opens https://yourdomain.com/products/abc123, the router matches the route and renders the correct component. This works regardless of whether the user is in a browser, an installed PWA, or came from a shared link.
Service Worker and Deep Links
Service workers can intercept navigation requests. This is important for deep links because:
- Offline deep links: If the user taps a deep link while offline, the service worker can serve a cached version of the page.
- Navigation preloading: The service worker can start fetching data before the page renders.
Handling Navigation in the Service Worker
// service-worker.js
self.addEventListener('fetch', (event) => {
// Only handle navigation requests (deep links)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
// Offline: serve the cached app shell
return caches.match('/offline.html');
})
);
return;
}
// Handle other requests (API calls, assets)
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request);
})
);
});
App Shell Pattern for Deep Links
With the app shell pattern, the service worker always serves the same HTML shell, and the client-side router handles the URL:
// service-worker.js
const APP_SHELL = '/index.html';
const CACHE_NAME = 'app-shell-v1';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([APP_SHELL, '/app.js', '/styles.css']);
})
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
// Always serve the app shell for navigation requests
// The client-side router will handle the URL
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(APP_SHELL);
})
);
}
});
This ensures deep links work even offline (the shell loads, then the router reads the URL and displays the correct view, though API data may not be available).
Bridging PWA and Native App
When you have both a PWA and a native app, you need to handle the case where the user has the native app installed.
Smart Banners
Show a banner on the PWA that links to the native app. Apple provides a meta tag for iOS:
<meta name="apple-itunes-app"
content="app-id=YOUR_APP_ID, app-argument=https://yourdomain.com/products/abc123">
For cross-platform banners with more control, see smart banners for Progressive Web Apps.
Intent Handling (Android)
On Android, if the user has your native app installed, App Links will open it instead of the PWA. No extra work is needed; the OS handles the routing.
If you want the user to choose between the PWA and native app, you can use getInstalledRelatedApps():
// Check if the native app is installed (Android Chrome only)
if ('getInstalledRelatedApps' in navigator) {
const relatedApps = await navigator.getInstalledRelatedApps();
const nativeApp = relatedApps.find(app => app.platform === 'play');
if (nativeApp) {
// Show "Open in app" option
showNativeAppPrompt(nativeApp);
}
}
This requires the related_applications field in your manifest:
{
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.yourcompany.yourapp",
"id": "com.yourcompany.yourapp"
}
]
}
Share Target API
PWAs can receive shared links from other apps using the Web Share Target API:
{
"share_target": {
"action": "/share-handler",
"method": "GET",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
When another app shares a URL to your PWA, it opens with query parameters:
https://yourdomain.com/share-handler?url=https://example.com/shared-content
Handle this in your router:
function ShareHandler() {
const params = new URLSearchParams(window.location.search);
const sharedUrl = params.get('url');
if (sharedUrl) {
// Process the shared URL
processSharedContent(sharedUrl);
}
return <ShareConfirmation />;
}
URL Handling for Installed PWAs
When a PWA is installed and the user clicks a link within the PWA's scope, it opens in the PWA rather than the browser. This is controlled by the scope in the manifest and the capture_links field (where supported):
{
"scope": "/",
"capture_links": "existing-client-navigate"
}
capture_links values:
none: Links open in the browser (default)new-client: Links open in a new PWA windowexisting-client-navigate: Links open in the existing PWA window
Note: capture_links is not yet widely supported. Check caniuse.com for current browser support.
Testing PWA Deep Links
Desktop Browser
Navigate directly to deep link URLs:
https://yourdomain.com/products/abc123
Installed PWA
- Install the PWA (Chrome menu, "Install app").
- Open a link from another app (email, messages) and verify it opens in the PWA.
- Test offline by disconnecting and opening a cached deep link.
Mobile Browser
# Android: test that App Links open the native app (not the PWA)
adb shell am start -a android.intent.action.VIEW \
-d "https://yourdomain.com/products/abc123" \
-c android.intent.category.BROWSABLE
# iOS: test Universal Links (PWA won't receive these)
xcrun simctl openurl booted "https://yourdomain.com/products/abc123"
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Deep link opens browser instead of PWA | scope doesn't cover the path |
Expand scope in manifest |
| PWA shows blank page on deep link | Service worker returns wrong resource | Use app shell pattern for navigation requests |
| Offline deep link shows error | No cached fallback | Cache the app shell and serve it for navigation |
| Native app opens instead of PWA | App Links/Universal Links take priority | This is expected behavior; native apps have priority |
Tolinku for PWAs
Tolinku's smart banners work with PWAs to promote native app installs. The web SDK detects whether the user has the native app installed and shows contextual banners. Tolinku also provides deferred deep linking, so users who install the native app from a PWA banner land on the correct content.
For smart banner implementation, see smart banners for Progressive Web Apps. For the full guide, see cross-platform deep linking guide for 2026.
Get deep linking tips in your inbox
One email per week. No spam.