Web Share Target API: Receiving Shared Content in a PWA
The Web Share Target API is the receiving half of native sharing: instead of your app pushing content out through navigator.share(), it registers as a destination in the operating system share sheet so that other apps can push text, links, and files into it. This reference covers the entire receiving pipeline — the share_target manifest member, the GET-versus-POST transport split, MIME-filtered file acceptance, the service worker fetch interception that turns a POST share into FormData, and the installation and secure-context prerequisites that gate the whole feature.
Where the sending side is a single JavaScript call, the receiving side is almost entirely declarative: you describe your target in the manifest and the browser wires it into the OS. The only imperative code lives in the service worker, and only when you accept POST payloads. That inversion — declarative registration, event-driven delivery — is what makes the receiving side feel unfamiliar even to developers comfortable with navigator.share().
Architecture Mandates
Before the OS will ever list your app in a share sheet, four conditions must hold. Unlike the sending side, none of them is a runtime JavaScript guard — they are properties of how the app is served and installed.
- Secure context — the manifest, service worker, and every share destination URL must be served over HTTPS from a secure context. Installation of a PWA is itself gated on a secure origin, so an insecure page can never register a share target. The exact rules are covered in understanding secure context requirements.
- Installed PWA — a
share_targetmember in an uninstalled site’s manifest does nothing. The target is materialised into the OS share sheet only after the user adds the app to the home screen or app launcher. - Manifest declaration, not feature flag — there is no
navigator.shareTarget. Registration is entirely declarative. Detection means checking prerequisites (window.isSecureContext,navigator.serviceWorker, installed display mode), never probing a method. - Payload validation on arrival — incoming shares are attacker-influenced input from another app. Validate MIME types, file sizes, and string lengths in the service worker or landing page before persisting or rendering anything.
API Surface Reference
The receiving side has no runtime method surface; its “API” is the share_target manifest member plus the standard service worker fetch event. The table below documents the manifest fields and the request-handling touchpoints.
| Member / Interface | Shape | Purpose | Notes |
|---|---|---|---|
share_target.action |
string (URL) |
Destination the OS invokes with the share | Must be same-origin and within manifest scope |
share_target.method |
"GET" | "POST" |
HTTP method for delivery | Defaults to GET; files require POST |
share_target.enctype |
"application/x-www-form-urlencoded" | "multipart/form-data" |
Encoding of the POST body | multipart/form-data is mandatory for files |
share_target.params.title |
string |
Form field name mapped to the shared title | Optional; delivered as query param on GET |
share_target.params.text |
string |
Field name for shared text | Often carries the body of a shared message |
share_target.params.url |
string |
Field name for a shared URL | Some senders fold the URL into text |
share_target.params.files |
Array<{ name, accept }> |
File field definitions with MIME filters | accept may be a MIME type, wildcard, or extension |
FetchEvent.request.formData() |
Promise<FormData> |
Reads the POST body in the service worker | Only valid for multipart/form-data POST shares |
URLSearchParams |
standard | Reads GET share params on the landing page | Parse from location.search |
For a GET share, the browser navigates to action with the mapped params appended to the query string, and no service worker interception is required. For a POST share, the browser issues a POST request to action that your service worker must catch in its fetch handler.
Declaring the share_target Manifest Member
The share_target member lives in your web app manifest alongside name, icons, and start_url. It names the URL the OS should open when the user picks your app, the HTTP method, and a params object that maps share fields (title, text, url, files) onto form field names your code will read. The full field-by-field walkthrough — including scope rules and how the mapping interacts with your router — lives in registering a Web Share Target.
A minimal text-only target uses GET:
{
"name": "Notes PWA",
"start_url": "/",
"display": "standalone",
"share_target": {
"action": "/share",
"method": "GET",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
With this manifest installed, a share of a web page delivers a navigation to /share?title=Example&text=Some+copy&url=https://example.com. The action must be same-origin and within the manifest’s navigation scope, or the browser rejects the registration silently at install time. Keep action distinct from start_url so your router can branch on it cleanly.
GET vs POST: Choosing method and enctype
The transport split is the single most important design decision on the receiving side. method and enctype together determine whether a share arrives as a URL navigation or as an intercepted request body — and whether files are even possible.
GET is the default and is correct for text, titles, and URLs. The share arrives as query parameters on a normal navigation, so no service worker is required to read it; the landing page parses location.search. GET cannot carry binary data, so any file field is dropped.
POST is required the moment you want files. Set method to "POST" and enctype to "multipart/form-data"; the share is delivered as a request body that only a service worker fetch handler can read. The trade-offs, and how a single app can register both shapes, are detailed in handling GET vs POST share targets.
{
"share_target": {
"action": "/share-inbox",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/jpeg", "image/png", "image/webp"]
}
]
}
}
}
Use application/x-www-form-urlencoded only for a POST that carries text but no files — a rare case. In practice the decision is binary: GET for text, POST with multipart/form-data for anything that might include a file.
There is a subtle interaction with your router worth planning for up front. A GET share is an ordinary navigation, so client-side routers, prerendering, and analytics all see it as a normal page load of action — the shared data simply rides along in location.search. A POST share never reaches your page framework directly; it is consumed and swallowed by the service worker, which then triggers a second, GET navigation to whatever URL you redirect to. Designing action and the redirect target as two distinct routes keeps that two-step dance legible: action is a machine endpoint that never renders, and the redirect target is the human-facing screen. Conflating them forces the same route to behave differently depending on HTTP method, which is a reliable source of confusion during debugging.
Accepting Files with MIME accept Filters
Each entry in params.files declares a form field name and an accept list. The accept values act as a filter: the sending app only offers your target for content whose MIME type matches. An accept of ["image/*"] surfaces your app for any image; ["application/pdf"] restricts it to PDFs; a mix of explicit types narrows it precisely. File extensions (.csv) are also honoured by some senders, but MIME types are the reliable form. The filtering semantics, wildcard behaviour, and per-sender quirks are covered in filtering shared file types with MIME accept.
{
"share_target": {
"action": "/share-inbox",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{ "name": "images", "accept": ["image/*"] },
{ "name": "documents", "accept": ["application/pdf", "text/plain", ".md"] }
]
}
}
}
Two field entries let one target accept both images and documents into separate form fields, which simplifies branching in the service worker. Be conservative: an over-broad accept such as ["*/*"] makes your app appear for every share on the device, which reads as noise and erodes trust. Declare only the types your app can actually process, and re-validate the real MIME type on arrival — a sending app can attach a file whose declared type does not match its bytes.
The number of File objects that land in a single field is also outside your control. A user sharing five photos from a gallery delivers five entries under the same field name, which is why the service worker reads them with formData.getAll(name) rather than get(name). Plan storage and UI for the many-file case even if your typical share is a single item, and impose an explicit cap: iterate the returned array, reject anything beyond a sensible count or cumulative size, and surface a clear message rather than silently dropping the overflow. Matching accept on both the field declaration and a runtime check gives you defence in depth — the manifest filter narrows what the OS offers, and the arrival-time check guards against a sender that ignores or misreports the filter.
Intercepting POST Shares in the Service Worker
A POST share is an HTTP POST to your action URL, and the only place to read it is the service worker fetch handler. Match on the request method and URL, read the body with request.formData(), extract the fields your manifest declared, and respond with a redirect to your app UI. The service worker must already be registered and active — which is exactly the machinery covered across the offline section and its service worker caching strategies guide.
Because the share request is a navigation, returning the payload directly would leave the user staring at your action URL. The established pattern is to persist the payload (Cache API or IndexedDB) and issue a 303/302 redirect to a clean UI route that reads it back. That keeps the share out of the browser history and gives the SPA a stable entry point.
// sw.js — intercept POST shares to the /share-inbox action
self.addEventListener('fetch', function onFetch(event) {
const url = new URL(event.request.url);
const isShareInbox =
event.request.method === 'POST' && url.pathname === '/share-inbox';
if (!isShareInbox) return; // let all other requests fall through
event.respondWith(handleSharedPost(event.request));
});
/**
* Reads the multipart share, persists it, and redirects to the UI route.
* @param {Request} request - The POST share request.
* @returns {Promise<Response>} A redirect to /shared.
*/
async function handleSharedPost(request) {
const formData = await request.formData();
const payload = {
title: formData.get('title') ?? '',
text: formData.get('text') ?? '',
url: formData.get('url') ?? '',
files: formData.getAll('media').filter(function (v) {
return v instanceof File;
})
};
await storeSharedPayload(payload);
return Response.redirect('/shared', 303);
}
The storeSharedPayload step is deliberately separated so the transport concern (parsing the request) stays independent of the persistence concern. The details of reading the parts and the persistence handshake are expanded in receiving shared files and text and, specifically for the body parsing, parsing multipart form data in a service worker.
Two timing hazards are worth calling out. First, the service worker that will handle the POST must already be active when the share arrives — if the user shares into a freshly installed app whose worker has not yet claimed the page, the first share can slip through to the network and 404. Call self.clients.claim() in the worker’s activate handler and pre-cache the /shared route as part of your app-shell precaching so the redirect target is available offline. Second, a request body can be read exactly once; calling request.formData() a second time throws, so parse it in a single pass and hand the extracted values onward rather than re-reading. Because event.respondWith() must receive a Response promptly, keep the synchronous part of the handler thin and let the async function do the awaiting — returning the promise, not blocking on it.
Guarding the match condition tightly matters as much as the parse. The fetch handler runs for every request the page makes, so an over-broad condition that returns a redirect for unrelated POSTs will break form submissions elsewhere in the app. Pin the match to both the exact pathname and method === 'POST', and return early for everything else so those requests fall through to the network untouched.
Extracting FormData and Forwarding to App State
Persisting the payload from the fetch handler and reading it back in the app are two halves of one handoff. The Cache API is a convenient store because the service worker and the page share it; IndexedDB is the durable choice for larger files, mirroring the caching strategies used elsewhere in the app. The landing route reads the stored payload, hydrates application state, and clears the store so a reload does not replay a stale share. The end-to-end wiring is detailed in forwarding received shares to app state.
// sw.js — store the shared payload in a dedicated cache
/**
* Persists a shared payload so the /shared route can read it after redirect.
* @param {{ title: string, text: string, url: string, files: File[] }} payload
* @returns {Promise<void>}
*/
async function storeSharedPayload(payload) {
const cache = await caches.open('inbound-shares');
const body = JSON.stringify({
title: payload.title,
text: payload.text,
url: payload.url
});
await cache.put('/__shared-meta', new Response(body, {
headers: { 'content-type': 'application/json' }
}));
await Promise.all(payload.files.map(function (file, index) {
return cache.put('/__shared-file-' + index, new Response(file, {
headers: { 'content-type': file.type || 'application/octet-stream' }
}));
}));
}
// app.js — the /shared route reads the payload back into app state
/**
* Loads the most recent inbound share and clears it from the cache.
* @returns {Promise<{ title: string, text: string, url: string, files: File[] } | null>}
*/
export async function consumeSharedPayload() {
if (!window.isSecureContext || !('caches' in window)) return null;
const cache = await caches.open('inbound-shares');
const metaResponse = await cache.match('/__shared-meta');
if (!metaResponse) return null;
const meta = await metaResponse.json();
const files = [];
for (let index = 0; ; index += 1) {
const fileResponse = await cache.match('/__shared-file-' + index);
if (!fileResponse) break;
const blob = await fileResponse.blob();
files.push(new File([blob], 'shared-' + index, { type: blob.type }));
await cache.delete('/__shared-file-' + index);
}
await cache.delete('/__shared-meta');
return { ...meta, files };
}
Clearing the cache entries as they are consumed is what prevents a browser refresh of /shared from resurrecting an already-handled share. Treat every extracted string and file as untrusted: enforce length caps on text, verify file sizes, and sniff real MIME types before rendering.
The Cache API is convenient because both the worker and the page reach it without a message channel, but it is not the only handoff. For large media, IndexedDB stores the File/Blob directly and avoids the extra Response wrapping; for small text-only shares, a query parameter on the redirect (Response.redirect('/shared?from=share', 303)) is enough to signal the page to look. Whichever store you pick, keep the read idempotent: a user can background the app mid-share and return later, so the /shared route should tolerate an empty store and render its normal empty state rather than throwing. Wrapping the consume step in the same window.isSecureContext and capability guards used everywhere else keeps the receiving path consistent with the rest of the codebase, and means a non-supporting engine that somehow reaches /shared degrades to the empty state instead of erroring.
Detecting Support and Installation
There is no navigator.canShareTarget and no feature flag to probe — the receiving capability is not exposed to script at all. Instead you detect the prerequisites: a secure context, a service worker, and whether the app is currently running as an installed PWA. The installed state is the practical signal that a share target is live, since the OS only surfaces the target for installed apps.
// share-target-support.js — infer receiving-side readiness from prerequisites
/**
* Reports whether this environment can act as a share target.
* There is no direct feature flag; we check the prerequisites instead.
* @returns {{ secure: boolean, serviceWorker: boolean, installed: boolean }}
*/
export function detectShareTargetReadiness() {
const secure = typeof window !== 'undefined' && window.isSecureContext === true;
const serviceWorker = secure && 'serviceWorker' in navigator;
// Installed PWAs run in standalone (or minimal-ui) display mode.
const installed =
secure &&
(window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true);
return { secure, serviceWorker, installed };
}
You cannot know from script whether a specific other app will offer your target — that depends on the sender honouring your accept filters. What you can do is confirm your own side is configured (installed, secure, service worker active) and, when it is not, guide the user toward installation. Listen for the appinstalled event to know the moment a target becomes available, and use the standalone display-mode query on load to branch UI for installed versus tabbed sessions.
A common mistake is to treat the absence of these prerequisites as an error state. It is not — on iOS Safari or Firefox the checks will always report the app as un-installed-as-target because those engines never expose the capability, and that is the expected steady state, not a fault. The right response is to route those users to the outbound experience rather than nagging them to install something that would not gain them a share target anyway. Reserve installation prompts for Chromium-based Android and desktop, where installing genuinely unlocks the receiving side. The detectShareTargetReadiness() result is therefore best read as a routing signal — “offer inbound UI here, offer outbound UI there” — rather than a pass/fail gate.
Cross-Browser Behaviour
Share target support is far narrower than the sending side. The receiving capability is Chromium-and-Android centric; two major engines omit it entirely.
| Browser / Platform | share_target |
POST + files | Notes |
|---|---|---|---|
| Chrome / Android | Yes | Yes | Reference implementation; installed PWA required |
| Edge / Android | Yes | Yes | Chromium parity with Chrome |
| Samsung Internet / Android | Yes | Yes | Supported; test accept filtering on older versions |
| Chrome / Edge desktop | Yes | Yes | Installed PWA appears in the OS share flow (Windows, ChromeOS) |
| Safari / iOS | No | No | No share_target; users cannot share into your app |
| Safari / macOS | No | No | Same omission as iOS |
| Firefox / Android | No | No | Not implemented; Firefox desktop likewise absent |
Key platform notes:
- iOS Safari has no receiving side. iPhone and iPad users can never pick your PWA from the iOS share sheet. Native iOS Share Extensions are the only route there, and they require a wrapped native app — out of scope for a pure PWA.
- Firefox omits
share_targetentirely on both desktop and Android, even though Firefox for Android supports the sending side experimentally. - Desktop delivery is OS-dependent. Installed PWAs on Windows and ChromeOS integrate with the system share flow; the exact surface differs by OS version.
- The app must stay installed. Uninstalling removes the target immediately; there is no lingering registration.
Progressive Enhancement Strategy
Because two major platforms cannot receive shares at all, the receiving feature must be an enhancement layered on top of a base that never assumes it. Route users through this decision sequence:
- Serve everything from a secure context. Without HTTPS there is no installability and therefore no share target — start here.
- Register and activate a service worker. POST share interception depends on it; wire it up as part of your offline baseline.
- Declare
share_targetin the manifest. Add the GET or POST shape appropriate to your content; installed Chromium browsers pick it up automatically. - Detect installation, not the API. Use
detectShareTargetReadiness()to branch UI. If the app is not installed, prompt installation rather than assuming the target exists. - Fall back to being the sender. On iOS Safari and Firefox — where receiving is impossible — offer the outbound path with
navigator.share(), and layer clipboard, QR, or link fallbacks beneath that, as covered in permission flows and progressive enhancement. - Validate every inbound payload. Cap text length, verify file size and real MIME type, and reject anything outside your declared
acceptset before persisting.
The critical mental shift: a browser that cannot receive a share can almost always still send one. When your target is unavailable, pivot the UX to outbound sharing so the user is never left without a path.
Error Handling Reference
Receiving-side failures rarely surface as thrown DOMExceptions — most manifest problems fail silently at install time. The table maps the symptoms you will actually observe to their causes and fixes.
| Symptom | Cause | Recovery |
|---|---|---|
| App never appears in the share sheet | Not installed, or share_target missing/invalid |
Prompt installation; validate manifest JSON and action scope |
| Appears for text but not files | method is GET, or enctype is not multipart/form-data |
Switch to POST + multipart/form-data; declare params.files |
TypeError on request.formData() |
Reading the body of a non-multipart request, or reading it twice | Guard on method === 'POST' and correct pathname; read the body once |
SecurityError / registration ignored |
action is cross-origin or outside manifest scope |
Make action same-origin and within navigation scope |
| Files arrive empty | accept did not match the shared MIME type |
Broaden accept to the real types; re-validate on arrival |
/shared replays an old share on refresh |
Payload not cleared after consumption | Delete cache/IndexedDB entries in consumeSharedPayload() |
Landing route shows raw action URL |
Missing redirect after POST | Return Response.redirect('/shared', 303) from the fetch handler |
For the deep dive on reading the request body correctly, see parsing multipart form data in a service worker.
FAQ
Does the Web Share Target API require the PWA to be installed?
Yes. A share target only appears in the operating system share sheet after the user installs the PWA to the home screen or app launcher. An uninstalled site loaded in a browser tab is never offered as a share destination, even with a perfectly valid share_target manifest member. This is why detection focuses on the installed display mode rather than any script-level flag.
When should I use POST instead of GET for a share target?
Use POST with enctype set to multipart/form-data whenever the share can include files. GET encodes title, text, and url as query parameters on a navigation and cannot carry binary file data. A POST share is delivered as a request body that your service worker fetch handler must intercept with request.formData(). For text-only targets, GET is simpler and needs no service worker.
How do I detect Web Share Target support in JavaScript?
There is no navigator method for the receiving side, so you detect prerequisites instead: check window.isSecureContext, confirm 'serviceWorker' in navigator, and test the installed state with window.matchMedia('(display-mode: standalone)'). Together these tell you whether your app is configured to receive. You cannot detect whether a given external app will honour your accept filters.
Which browsers support the Web Share Target API?
Chrome, Edge, and Samsung Internet on Android support registering a share target, and desktop Chrome and Edge support it for installed PWAs on Windows and ChromeOS. iOS Safari and Firefox do not implement share_target at all. For those users you must fall back to being the sender via navigator.share(), with clipboard or QR-code cross-device sharing beneath it.
Why is my share target not receiving files?
File delivery requires three things together: method of POST, enctype of multipart/form-data, and a files entry in params whose accept list matches the incoming MIME types. If any one is missing, the sending app either omits your target from the share sheet for that content or delivers the share with empty file fields. Confirm all three, then re-validate the actual file type on arrival.
Related
- Registering a Web Share Target — the full
share_targetmanifest member,actionscope, and params mapping - Receiving Shared Files and Text — parsing
FormData, persisting payloads, and hydrating app state - Web Share API & Security Contexts — HTTPS and secure-context requirements the receiving side inherits
- Offline-First PWA Patterns — service worker registration and caching that POST interception depends on
- Permission Flows & Progressive Enhancement — outbound fallbacks for iOS Safari and Firefox where receiving is impossible
- QR Code Generation for Cross-Device Sharing — a network-free bridge when no share target is available