Skip to content

handle_links: opting an installed PWA into link handling

In one line: handle_links is a proposed web app manifest member that lets an installed PWA state a preference for whether in-scope links should open inside the installed app rather than the browser, via three values — auto, preferred, and not-preferred.

Per the WICG explainer, the member’s shape is "handle_links": "auto" | "preferred" | "not-preferred":

  • preferred — the user agent should open in-scope links within the installed application.
  • not-preferred — the user agent should not open links within the installed application.
  • auto — the user agent should select the appropriate behavior for the platform. This is the default value if handle_links is not found in the manifest.

These are stated as suggestions from the app to the user agent, not a hard guarantee.

Section titled “Where it fits among the manifest’s link members”

The explainer’s Non-Goals section is explicit that handle_links does not do everything a link-handling PWA might need:

  • It does not extend which origins count as in-scope — that is scope_extensions, which handle_links can be combined with once a link’s origin has been brought into scope.
  • It does not customize what happens once the app launches (routing, window placement) — that is launch_handler.
  • handle_links only governs whether an in-scope link opens the installed app at all.

Chrome’s own tracking entry on chromestatus.com lists its implementation status as “On hold”, with no origin trial. The same tracking data shows “No signal” positions from both Firefox and Safari. MDN’s web app manifest reference page does not list handle_links among the manifest members it documents. Treat it as a proposal-stage member whose only tracked status across the three engines is “on hold” or “no signal” — check chromestatus.com and MDN again before depending on it.

{
"name": "Example PWA",
"start_url": "/app/",
"scope": "/app/",
"handle_links": "preferred"
}

Per the Web App Manifest spec’s scope-processing rules, if start_url is not within the declared scope, the user agent ignores scope and falls back to a default scope derived from start_url instead — so start_url must stay inside scope for the declared boundary to actually apply.

The WICG explainer describes handle_links only as a manifest member read by the browser; it defines no companion JavaScript property or runtime API for reading it back, and no feature-detectable object or CSS media feature exists for the member itself. Checking display-mode does not detect handle_links support — it only tells a page which display mode it is currently rendered in, which is a weak, unrelated signal of “looks like an installed app.” Per MDN, a PWA sets its display mode by setting the manifest’s display member, and the reported display-mode value reflects standalone or minimal-ui when the browser honors that request (MDN notes this may not match what was requested, since a browser can decline to honor it). MDN also documents window-controls-overlay as a further standalone-style desktop mode. fullscreen and picture-in-picture, by contrast, can also be triggered by any web app at runtime via the Fullscreen API or the Document Picture-in-Picture API — so a match there is ambiguous with an installed app and is not a useful signal on its own.

Because the explainer defines no runtime detection mechanism for handle_links, keep regular in-page navigation working regardless of whether the browser honors handle_links, and use display-mode only to adjust presentation (not navigation logic):

const installedModes = ["standalone", "minimal-ui", "window-controls-overlay"];
const looksInstalled =
window.matchMedia &&
installedModes.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches);
if (looksInstalled) {
// Rendered in standalone, minimal-ui, or window-controls-overlay — a
// signal, not a guarantee, of running as the installed app. Adjust
// presentation only, e.g. hide a browser-only "Install" button:
document.querySelectorAll("[data-install-prompt]").forEach((el) => el.hidden = true);
} else {
// Rendered in browser, fullscreen, or picture-in-picture — the default
// mode, or a mode any web app (installed or not) can also enter at
// runtime. This signal cannot confirm whether handle_links is
// unsupported or simply inactive, so show the browser-only "Install"
// button instead of hiding it:
document.querySelectorAll("[data-install-prompt]").forEach((el) => el.hidden = false);
}
// Either branch: in-page links keep their default anchor navigation — the
// app never intercepts clicks based on handle_links, since its effective
// state cannot be read back at runtime.
  • Don’t rely on handle_links alone to route users into your installed app — its chromestatus.com tracking status is “On hold” in Chrome and “No signal” in Firefox and Safari.
  • If you add it now, treat it as a forward-looking declaration for a proposal-stage member — its only tracked engine status is “On hold” (Chrome, no origin trial) or “No signal” (Firefox, Safari).
  • Combine it with scope_extensions when the goal is opening links from other origins in the installed app — handle_links alone only covers your existing scope.
  • Re-check chromestatus.com and MDN before depending on this in production; the explainer is still WICG-stage and unshipped.