Web App Manifest¶
The web app manifest is a JSON document, linked from your pages with <link rel="manifest">, that tells the browser how your site behaves once it is installed: its name and icons, the URL it launches to, the set of URLs that belong to it, how its window looks, and how it integrates with the operating system. It is one of the two core building blocks of a Progressive Web App (the other is the service worker), and it is the part that turns a website into something with an identity on the user's device. Getting it right is mostly about understanding how browsers fetch, validate and apply it, because every mistake fails silently: invalid values are simply ignored.
Key takeaways
- The core spec is a W3C Working Draft from the Web Applications Working Group (latest publication 13 August 2026); many widely used members live in separate documents: the App Information Note, WICG incubations and vendor extensions.
- Serve the file as
application/manifest+json(the registered.webmanifestextension) and link it once, in the top-level document. Only the first<link rel="manifest">counts. - The manifest is fetched in CORS mode, and Chromium sends no cookies unless you add
crossorigin="use-credentials", even for a same-origin manifest. - Relative URLs resolve against the manifest URL, not the page. A manifest on a CDN origin therefore silently loses a relative
start_url. - Browsers never coerce types. A wrong type, unknown keyword, unparsable color or cross-origin URL makes that member (or list entry) disappear and its default applies.
- Safari needs no manifest at all to create a Home Screen or Dock web app (iOS 26 and macOS), but it still reads one when present; Chromium requires specific members before it offers installation.
- Always inspect the result in DevTools (Application → Manifest), not the raw file: the computed
id, scope and installability errors are what the browser actually uses.
Pages in this section¶
-
Members reference
Every member, its type, default, validation rules, per-browser support, JSON examples and gotchas.
-
Icons & maskable icons
icons,purpose, the 40% safe zone, monochrome icons, sizes per platform and how icon updates work. -
Display modes
display,display_override, fallback chains, thedisplay-modemedia query and platform quirks. -
App identity & updates
How
id,start_urlandscopedefine an app, and how browsers detect and apply manifest updates. -
Rich install UI
descriptionandscreenshots, the app-store style install dialog and its image constraints. -
App shortcuts
Context-menu and jump-list shortcuts: limits, icons, localization and launch behavior.
-
Advanced & integration members
share_target,file_handlers,protocol_handlers,launch_handler,scope_extensionsand more.
What the web app manifest is (and what it is not)¶
In spec terms, a manifest is "a JSON document that contains startup parameters and application defaults for when a web application is launched". Every member is optional, members may appear in any order, and unknown members are ignored, which is what makes the format extensible. When a browser applies a processed manifest to a top-level browsing context (a window or tab), that context becomes an application context: it gets the manifest's display mode, theme color and orientation for as long as the user stays within the manifest's navigation scope.
The manifest describes; it does not execute. It has no effect on offline behavior, caching or background work (that is the service worker's job), and a manifest alone does not make a page installable in every browser (see Installability criteria). It is also not a security boundary: scope controls presentation, not what the app can navigate to.
The members fall into five functional groups:
| Group | Members | Covered in depth |
|---|---|---|
| Identity and naming | id, name, short_name, description, lang, dir, *_localized | App identity & updates |
| Launch and navigation | start_url, scope, scope_extensions, launch_handler | App identity & updates, Advanced members |
| Presentation | display, display_override, orientation, theme_color, background_color, tab_strip | Display modes, Splash screens & theming |
| Imagery and store metadata | icons, screenshots, categories, iarc_rating_id, related_applications | Icons, Rich install UI |
| OS integration | shortcuts, share_target, file_handlers, protocol_handlers, note_taking | Shortcuts, Advanced members |
Specification status and where each member is defined¶
The Web Application Manifest specification is developed by the W3C Web Applications Working Group on the Recommendation track. As of September 2026 its latest publication is the Working Draft of 13 August 2026; it has remained a Working Draft since the First Public Working Draft of 17 December 2013 and has never entered Candidate Recommendation. The current editors are Marcos Cáceres (Apple), Daniel Murphy (Google) and Christian Liebel (Thinktecture AG). The document itself carries a warning that it "is not stable", and the editor's draft at w3c.github.io/manifest changes more often than the dated snapshots.
The core spec deliberately stays small. Its root members are background_color, dir, display, icons, id, lang, name, orientation, scope, short_name, shortcuts, start_url and theme_color, plus two recent additions: the *_localized family (name_localized, short_name_localized, icons_localized) and color_scheme_dark. Everything else is defined elsewhere and hooks into the core processing algorithm through its processing extension point:
| Document | Venue and maturity | Members it defines |
|---|---|---|
| Web Application Manifest | W3C WebApps WG, Working Draft (13 Aug 2026) | 13 core members, *_localized, color_scheme_dark, shortcut items, image purpose |
| Web App Manifest – Application Information | W3C WebApps WG, Group Note (21 Aug 2023) | categories, description, iarc_rating_id, screenshots |
| Manifest Incubations | WICG, unofficial draft | display_override, tab_strip, note_taking, protocol_handlers, file_handlers, related_applications, prefer_related_applications, scope_extensions, migrate_from, migrate_to, plus BeforeInstallPromptEvent |
| Web App Launch Handler API | WICG, unofficial draft | launch_handler |
| Web Share Target API | Editor's draft on w3c.github.io | share_target |
| Window Controls Overlay | WICG, unofficial draft | the window-controls-overlay display mode |
| Vendor documentation | Microsoft, Chromium | edge_side_panel, widgets, serviceworker, gcm_sender_id, Isolated Web App members |
The Application Information members are described as "supplementary": they are not applied at runtime and exist mainly for install dialogs and storefronts. The WICG "Manifest Incubations" document describes itself as documenting "extensions & incubations which Chromium has shipped but do not have commitments / implementations from other user agents", which is a useful rule of thumb: a member defined there is, today, a Chromium feature.
Deep dive: the rules for proprietary members
The core spec's extensibility section asks other specifications to hook in at the processing extension point, to process their member "atomically and self contained", and never to modify values already processed. For truly proprietary members it recommends prefixing the member with the name of the ecosystem (its examples are kpl_fancy_feature and blitzly_site_verification), not a browser vendor prefix that is meant to be removed later. Microsoft's ms_ac_template field inside widgets follows that pattern. Because all implementations "are free to ignore any member they do not recognize", adding a proprietary member never breaks other browsers.
File naming, MIME type and serving the manifest¶
The IANA-registered media type is application/manifest+json and the registered file extension is .webmanifest. Neither is mandatory: the spec says developers "can also choose a different extension (e.g. .json) or none at all", and "any JSON MIME type is ok". What is enforced is in the HTML Standard's processing step for rel="manifest": if the response's Content-Type is not a JSON MIME type (any type whose essence is application/json or text/json, or whose subtype ends in +json), the fetch is treated as a failure. Chromium's manifest fetcher currently parses any 2xx or 3xx response regardless of Content-Type, but relying on that leniency is a bug waiting for another engine to expose it.
The specification decodes the body as UTF-8 (a leading byte-order mark is tolerated; Chromium also honors a charset parameter on the response) and parses it as strict JSON. Comments are not JSON; Chromium still tolerates them through a deprecated parser path and counts their use so the tolerance can be removed, so never ship them.
Configure the type explicitly, because many servers do not map .webmanifest out of the box and fall back to application/octet-stream:
import express from "express";
const app = express();
app.use(
express.static("dist", {
setHeaders(res, filePath) {
if (filePath.endsWith(".webmanifest")) {
// Set the registered type explicitly instead of trusting the mime table.
res.type("application/manifest+json");
// Allow caching, but force revalidation so updates are seen quickly.
res.set("Cache-Control", "no-cache");
}
},
}),
);
app.listen(8080);
Caching. The HTML Standard tells installed apps to re-fetch the manifest whenever a page's manifest link is inserted or its href changes, which in practice means on every page load. A long max-age or immutable directive therefore delays updates to installed apps by the cache lifetime, and a service worker that serves the manifest cache-first delays them indefinitely. Use no-cache with an ETag (cheap 304 revalidations) or a short max-age.
A stable URL. Do not content-hash the manifest file name the way you hash scripts. The spec's rules for processing a manifest without a document (for example during a background update or a sync-driven install) assume that some same-origin page links to that exact manifest URL. Keep the URL stable and let ETag handle freshness.
Linking the manifest with <link rel="manifest">¶
The HTML Standard defines precisely how this link behaves:
- Only the first one counts. "Only the first
linkelement in tree order whoserelattribute contains the tokenmanifestmay be used." A second manifest link, for example injected by a framework plugin, is ignored. - Top-level documents only. The fetch setup steps return early if the document's navigable is not a top-level traversable, so a manifest linked from inside an
<iframe>is never fetched. - It never blocks rendering. "A user agent must not delay the load event for this link type."
- Timing depends on install state. For a site that is not installed, the browser fetches the manifest "when the user agent deems it necessary", for example to evaluate installability or when the user chooses to install. For an installed app it is fetched when the link is inserted or its
hrefchanges. - No
Link:header equivalent. The "process a link header" steps formanifestare defined as doing nothing, soLink: </app.webmanifest>; rel=manifesthas no effect.
The request itself has these properties, which matter for your server, your CSP and your service worker:
| Request property | Value | Consequence |
|---|---|---|
| Destination and initiator | manifest | Service workers can match event.request.destination === "manifest" |
| Mode | cors | A cross-origin manifest needs Access-Control-Allow-Origin |
| Credentials | From the crossorigin attribute (see below) | Cookie-protected manifests fail without use-credentials |
| CSP directive | manifest-src (falls back to default-src) | A strict CSP must allow the manifest's origin |
| Client | The linking document | Requests pass through the page's service worker |
Credentials and the crossorigin attribute¶
This is where the specification and the dominant implementation disagree, so be explicit:
crossorigin attribute | HTML Standard credentials mode | Chromium (Chrome, Edge, Samsung Internet) |
|---|---|---|
| Absent | same-origin | omit: no cookies, even for a same-origin manifest |
anonymous | same-origin | omit |
use-credentials | include | include |
Chromium's manifest fetcher sets the credentials mode to include only when the attribute is exactly use-credentials and to omit otherwise, and Google's web.dev guide states that the manifest request "is made without credentials, even if it's on the same domain". MDN gives the same advice for all browsers. So if the manifest is generated behind authentication, sits behind an authenticating reverse proxy, or depends on a session cookie in any way:
For a cross-origin manifest fetched with credentials, the server must also answer with Access-Control-Allow-Origin: https://app.example.com (the exact origin, not *) and Access-Control-Allow-Credentials: true. The fetch of icons is separate: it uses destination image, is governed by the linking document's img-src directive, and also passes through the service worker.
The cross-origin manifest trap¶
Hosting the manifest on a CDN origin works, but every relative URL inside it now resolves against the CDN:
{
"name": "Acme Tasks",
"start_url": "/",
"scope": "/",
"icons": [{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }]
}
Linked from https://app.example.com/, start_url resolves to https://cdn.example.net/, fails the rule that it must be same-origin with the document, and is replaced by the URL of whatever page the user happened to install from. scope then defaults to that page's directory. The icons, however, correctly load from the CDN. Either serve the manifest from the app origin (recommended), or use absolute URLs for every navigational member (start_url, scope, id, shortcut urls, share_target.action, file_handlers[].action, protocol_handlers[].url).
The fetch-and-apply pipeline¶
sequenceDiagram
participant Doc as Document
participant SW as Service worker
participant Srv as Server
participant UA as Browser app layer
Doc->>Doc: Find the first manifest link in tree order
Doc->>SW: Fetch with destination manifest and mode cors
SW->>Srv: Network request or cached response
Srv-->>SW: 200 with Content-Type application/manifest+json
SW-->>Doc: Response
Doc->>UA: Process the manifest with document URL, manifest URL and bytes
UA->>UA: Validate each member, apply defaults, compute id and scope
UA-->>Doc: Processed manifest used for install UI, updates and launch How browsers process a manifest¶
The core spec defines processing as a single algorithm that takes the document URL, the manifest URL, the response body and the document's settings object, and produces a processed manifest: an ordered map of validated values. Understanding its rules explains almost every "my manifest is ignored" bug.
JSON errors produce an empty manifest¶
If the body is not valid JSON, or the top-level value is not an object (an array or a string, for example), the spec replaces it with an empty map and carries on. Every member then takes its default: the start URL becomes the document URL, the display mode becomes browser, and there is no name or icon. Chromium reports the parse failure in the console with a Manifest: prefix and the line and column of the error, and marks the manifest as failed so installability checks report "The manifest could not be fetched, parsed, or the document is on an opaque origin". Chromium also refuses to fetch a manifest at all from an opaque origin (a sandboxed document, for example) or an about: URL.
The processing order¶
The spec processes members in a fixed order, because later members depend on earlier ones:
| Step | Member | Depends on |
|---|---|---|
| 1 | dir | none |
| 2 | lang | none |
| 3 | name, then name_localized | dir (default direction) |
| 4 | short_name, then short_name_localized | dir |
| 5 | start_url | manifest URL, document URL |
| 6 | id | start_url |
| 7 | Identity check | a previously processed manifest for this document |
| 8 | scope | start_url, manifest URL |
| 9 | theme_color, background_color | none |
| 10 | display | none |
| 11 | icons, then icons_localized | manifest URL |
| 12 | color_scheme_dark | color processing rules |
| 13 | orientation | none |
| 14 | shortcuts | manifest URL, scope, dir |
| 15 | Extension point | all other specs' members (display_override, share_target, and so on) |
Step 7 is easy to miss: if the document already has a processed manifest and the new one has a different id, processing stops and the new manifest is discarded. Swapping the manifest link at runtime can change a name or icon, but it cannot turn the page into a different app.
URL resolution rules¶
Different members resolve against different bases and carry different restrictions. When a URL fails its restriction, the member (or list entry) is ignored:
| Member | Resolved against | Restriction | On failure |
|---|---|---|---|
start_url | Manifest URL | Same origin as the document | Document URL is used |
id | Origin of start_url | Same origin as start_url; fragment removed | start_url (without fragment) |
scope | Manifest URL | start_url must be within it; query and fragment removed | start_url's directory |
icons[].src, screenshots[].src | Manifest URL | None (CSP img-src governs the fetch) | Entry dropped |
shortcuts[].url | Manifest URL | Within scope | Shortcut dropped |
share_target.action, file_handlers[].action, protocol_handlers[].url, note_taking.new_note_url | Manifest URL | Within scope | Entry or member dropped |
related_applications[].url | Manifest URL | None | Entry needs an id instead |
scope_extensions[].origin | Absolute | Must be an https origin | Entry dropped |
"Within scope" is a deliberately simple test: the two URLs must be same-origin and the target's path string must start with the scope's path string. It is a prefix match, not a path-segment match, so a scope of /app also captures /application/. End every scope with /.
Invalid values are ignored, never coerced¶
Each member's processing steps check the JSON type first and return early on a mismatch. Strings are trimmed of leading and trailing whitespace, and enumerated values (display, orientation, dir) are ASCII-lowercased before comparison, but nothing is ever converted between types. The console messages below are the ones Chromium logs:
| Input | Result | Chromium console message |
|---|---|---|
"display": " Standalone " | standalone (trimmed, lowercased) | none |
"display": "window-controls-overlay" | Ignored, default browser applies | inapplicable 'display' value ignored. |
"name": 42 | Ignored | property 'name' ignored, type string expected. |
"prefer_related_applications": "false" | Ignored, default false | property 'prefer_related_applications' ignored, type boolean expected. |
"theme_color": "#12345" | Ignored | property 'theme_color' ignored, '#12345' is not a valid color. |
"start_url": "https://other.example/" | Document URL used | property 'start_url' ignored, should be same origin as document. |
"scope": "/app/" with "start_url": "/" | Default scope used | property 'scope' ignored. Start url should be within scope of scope URL. |
"icons": [{ "src": "a.png", "purpose": "fizzbuzz" }] | Icon dropped | found icon with no valid purpose; ignoring it. |
| 12 valid shortcuts | First 10 kept | property 'shortcuts' contains more than 10 valid elements, only the first 10 are parsed. |
Chromium surfaces these as console warnings (errors for fatal JSON problems) prefixed with Manifest:, and DevTools repeats them in the Application → Manifest pane.
Defaults when a member is absent¶
| Member | Default after processing |
|---|---|
start_url | The URL of the document that linked the manifest |
id | The processed start_url with its fragment removed |
scope | start_url with its file name, query and fragment removed (/shop/cart.html?x=1 gives /shop/) |
display | browser |
dir | auto |
lang | Unknown (no language) |
name, short_name | None; browsers may fall back to each other, to <meta name="application-name">, to <title>, or to a generic default name such as "Untitled" |
icons, shortcuts | Empty lists |
theme_color, background_color, orientation | None; the browser's own defaults apply |
The start-URL-derived defaults are why the spec recommends that you "always declare a scope member", preferably "/": without one, the scope depends on which page the user installed from.
Applying the manifest, and keeping it up to date¶
When the user launches an installed app, the browser creates a new top-level browsing context, applies the manifest (display mode, orientation, theme color) and navigates to the start URL with history handling set to replace, so the back button never leads to a blank pre-launch entry. Launching through a deep link, a shortcut, a file or a protocol handler navigates to that URL instead; the launch_handler member can redirect such launches into an existing window.
Navigating outside the scope does not block the navigation. Earlier drafts required that, but it broke third-party sign-in flows, so the spec now asks browsers to show "a prominent UI element indicating the URL or at least its origin" instead.
For updates, the spec splits members into security-sensitive ones (name, short_name, icons and their localized forms), which browsers should only change with the user's express permission, and everything else, which "should" be applied immediately. It also asks browsers to treat an icon as unchanged unless its src changes, mirroring Cache-Control: immutable. Chrome adopted this model in Chrome 144: the once-a-day update throttle is gone, icons are only re-downloaded when their URL or metadata changes in the manifest, and name or icon changes appear as an optional "Review app update" suggestion instead of a blocking dialog. The details, including why id must be set before you change start_url, are in App identity & updates.
Minimal vs recommended manifest¶
Browsers disagree about what a manifest must contain, so "minimal" depends on the target.
Safari has no requirements. Since macOS Sonoma any site can be added to the Dock as a web app, and since iOS 26 and iPadOS 26 every site added to the Home Screen opens as a web app by default. WebKit describes this as "zero requirements for installability". A manifest still customizes the result: "If you define your icons in the manifest, they're used."
Firefox for Android offers installation when the page is served securely, the manifest's display is not browser, and it has an icon whose purpose includes any or maskable with a size of at least 192 pixels (per the hasLargeIcons() check in Mozilla's Android components). Its parser also rejects a manifest with neither name nor short_name.
Chromium is the strictest. Its installability evaluator (the code behind the DevTools Installability section) checks the following, and reports the message shown when a check fails:
| Check | Chromium error message |
|---|---|
Served from a secure origin (HTTPS or localhost) | "Page is not served from a secure origin" |
| A manifest link exists and the manifest fetched and parsed | "Page has no manifest <link> URL" / "The manifest could not be fetched, parsed, or the document is on an opaque origin" |
An explicit, valid, same-origin start_url | "Manifest start URL is not valid" |
name or short_name | "Manifest does not contain a 'name' or 'short_name' field" |
display is standalone, fullscreen or minimal-ui, or the first recognized display_override entry is one of those or window-controls-overlay / tabbed (which are never valid in display) | "Manifest 'display' property must be one of 'standalone', 'fullscreen', or 'minimal-ui'" |
An icon with purpose any of at least 144 px, in PNG, SVG or WebP, with sizes set | "Manifest does not contain a suitable icon - PNG, SVG or WebP format of at least 144px is required, …" |
For install promotion only: prefer_related_applications is not true with a related app for this platform | "Manifest specifies prefer_related_applications: true" (the app stays installable from the browser menu) |
| Not in an incognito window | "Page is loaded in an incognito window" |
The 144 px threshold is the code's hard floor (48 dp at 3x density); Chrome's documentation asks for 192 × 192 and 512 × 512 icons, and you should provide both: Chrome picks the closest match for launcher icons and the Android splash screen. Chrome removed the service worker requirement for installing from the browser menu in Chrome 108 (Android) and 112 (desktop); the automatic prompt kept requiring a fetch handler for a while, and current Chromium's promotion pipeline has no service worker check. Current Chromium also has no engagement requirement before beforeinstallprompt (the old "click plus 30 seconds" rule is historical); see Installability criteria and Install prompts.
The smallest manifest that satisfies all three engines:
{
"name": "Acme Tasks",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
A production manifest adds identity, scope, theming, an adaptive icon and the rich-install metadata:
{
"id": "/",
"name": "Acme Tasks: Team To-Do Lists",
"short_name": "Tasks",
"description": "Plan, assign and track team tasks, online or offline.",
"lang": "en-US",
"dir": "ltr",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"theme_color": "#0b57d0",
"background_color": "#ffffff",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/monochrome.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "monochrome" }
],
"screenshots": [
{ "src": "/shots/board-wide.png", "sizes": "1280x800", "type": "image/png", "form_factor": "wide", "label": "Task board with three columns" },
{ "src": "/shots/list-narrow.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow", "label": "Today's tasks on a phone" }
],
"shortcuts": [
{ "name": "New task", "url": "/tasks/new", "icons": [{ "src": "/icons/add-96.png", "sizes": "96x96" }] },
{ "name": "Today", "url": "/tasks/today" }
],
"categories": ["productivity", "business"]
}
Why each addition matters:
idfreezes the app's identity, so you can later changestart_urlwithout users ending up with two installs.scope: "/"makes the scope independent of which page the user installed from.start_urlwith a query parameter lets analytics count launches from the installed app (see Analytics for PWAs). Never put a user identifier there: the spec calls that a fingerprint that survives clearing site data.- Separate
anyandmaskableicons avoid the shrunken look you get when a padded maskable icon is used unmasked. screenshotswith both form factors unlock Chrome's richer install dialog on desktop (wide) and Android (narrowor unspecified).
Every member, including the integration members omitted here, is documented in the members reference, and the manifest cheat sheet condenses it to one page.
Manifest vs legacy meta tags¶
Before manifests, each vendor invented its own <meta> and <link> tags. The spec's appendix on HTML explains why metadata moved into a separate JSON file: tags have to be duplicated in every page, fall out of sync, and force a user agent to re-download whole HTML documents to check for metadata updates. Some of these tags are still useful, some are fallbacks, and some are dead:
| Tag | Read by (2026) | Manifest counterpart | Recommendation |
|---|---|---|---|
<meta name="theme-color"> (HTML Standard) | Chromium, Safari 15+, Samsung Internet | theme_color | Keep. It overrides the manifest color per page within scope and supports media queries for light and dark schemes. Desktop Chrome and Edge use it only in installed apps, and since Safari 26 so does Safari; Chrome for Android also tints the regular browser toolbar. Firefox ignores it. |
<meta name="application-name"> (HTML Standard) | Chromium's fallback metadata | name | Optional fallback for manifest-less installs |
<link rel="icon"> | All browsers; Chromium's fallback install icon | icons | Keep for tabs and bookmarks |
<link rel="apple-touch-icon"> | Safari (iOS, iPadOS, macOS); also read by Chromium's fallback metadata | icons | Keep a 180 × 180 opaque PNG. MDN notes that Safari uses manifest icons only when no apple-touch-icon is present. |
<meta name="apple-mobile-web-app-capable"> | Safari before iOS 26; Chromium's fallback metadata | display | Legacy. iOS 26 opens every Home Screen site as a web app unless the user opts out. |
<meta name="mobile-web-app-capable"> | Chromium's fallback metadata | display | Legacy |
<meta name="apple-mobile-web-app-title"> | Safari | short_name | Optional; keep it consistent with short_name |
<meta name="apple-mobile-web-app-status-bar-style"> | Safari on iOS and iPadOS | none | Optional (default, black, black-translucent) |
<link rel="apple-touch-startup-image"> | Safari on iOS and iPadOS | background_color + icons | Optional; Safari does not generate splash screens from the manifest |
<meta name="msapplication-TileColor">, msapplication-TileImage, msapplication-config, msapplication-starturl, msapplication-navbutton-color | Internet Explorer and EdgeHTML-based Edge, both retired | theme_color, icons, start_url | Remove |
Chromium's page-metadata extractor, which supplies a name and icons when a site without a complete manifest is installed, reads <title>, application-name, description, <link rel="icon">, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed, mobile-web-app-capable and apple-mobile-web-app-capable (the Apple variant only when the unprefixed tag is absent).
A head that covers every engine without redundancy:
<link rel="manifest" href="/app.webmanifest">
<!-- Per-page theme color; media queries give light and dark variants. -->
<meta name="theme-color" content="#0b57d0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<!-- Tab icons for every browser. -->
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
<!-- Safari prefers this over manifest icons: 180x180, opaque, no rounded corners. -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- Optional Safari-only refinements. -->
<meta name="apple-mobile-web-app-title" content="Tasks">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
Theming across platforms, including status bars and splash screens, is covered in Splash screens & theming and iOS & iPadOS.
Dynamic and generated manifests¶
A manifest does not have to be a static file. Common reasons to generate it are localization, per-tenant branding on white-label platforms, and environment-specific values (a "Staging" name, for example). Three rules keep dynamic manifests safe:
- The
idmust not vary. Per the processing algorithm, a document ignores a later manifest with a differentid, and across installs a differentidmeans a different app. - Nothing user-specific. The manifest outlives the session, and identifiers in
start_urlor shortcut URLs survive clearing site data. - Keep one URL per variant and make caches aware of it. If you negotiate on a request header, say so with
Vary.
Server-generated manifests¶
Chrome and Edge 148 added *_localized members, which are now the preferred way to localize. For other browsers, or for tenant-specific branding, generate the manifest on the server:
import { createHash } from "node:crypto";
import express from "express";
const SUPPORTED = ["en", "de", "fr"];
const STRINGS = {
en: { name: "Acme Tasks", short: "Tasks", description: "Plan and track team tasks." },
de: { name: "Acme Aufgaben", short: "Aufgaben", description: "Teamaufgaben planen und verfolgen." },
fr: { name: "Acme Tâches", short: "Tâches", description: "Planifiez et suivez les tâches." },
};
export const manifestRouter = express.Router();
manifestRouter.get("/app.webmanifest", (req, res) => {
// Manifest requests carry Accept-Language even though they omit cookies.
const lang = req.acceptsLanguages(...SUPPORTED) || "en";
const text = STRINGS[lang];
const manifest = {
id: "/", // Identity never depends on the language.
lang,
dir: "ltr",
name: text.name,
short_name: text.short,
description: text.description,
start_url: "/?source=pwa",
scope: "/",
display: "standalone",
theme_color: "#0b57d0",
background_color: "#ffffff",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
};
const body = JSON.stringify(manifest);
// A strong validator lets browsers revalidate with a cheap 304.
const etag = `"${createHash("sha256").update(body).digest("base64url")}"`;
res.set({
"Content-Type": "application/manifest+json; charset=utf-8",
"Cache-Control": "no-cache",
Vary: "Accept-Language", // Tell CDNs and caches that the body varies by language.
"Content-Language": lang,
ETag: etag,
});
if (req.fresh) {
res.status(304).end();
return;
}
res.send(body);
});
Remember the consequence of the credentials rule: if the route needs the session (to brand a tenant, say), the page must use crossorigin="use-credentials", and the route must still return a complete manifest for requests without a session.
Swapping the manifest link on the client¶
The spec's internationalization appendix explicitly allows "dynamically adding or replacing the manifest link relationship", and the HTML Standard re-fetches the manifest of an installed app whenever the link's href changes. Replace the href of the existing element rather than appending a second link, because only the first one in tree order is used:
/**
* Point the page at a locale-specific manifest. Call it before the user installs;
* for an installed app the new name is a security-sensitive update that the
* browser shows to the user before applying.
*/
export function useLocalizedManifest(locale) {
const href = new URL(`/manifests/${encodeURIComponent(locale)}.webmanifest`, location.origin).href;
let link = document.querySelector('link[rel~="manifest"]');
if (!link) {
link = document.createElement("link");
link.rel = "manifest";
document.head.prepend(link); // Prepend so it is the first manifest link in tree order.
}
if (link.href !== href) {
link.href = href; // An href change triggers a re-fetch for installed apps.
}
}
Every variant must declare the same id, otherwise the replacement is ignored for the rest of the document's lifetime.
Why data: and blob: manifests are a trap¶
Inlining the manifest as a data: URL, or creating it with URL.createObjectURL(), is a popular trick for fully client-side configuration. Both are fragile:
- Relative URLs break. With a
blob:manifest URL, relative values such as"start_url": "/"or"src": "icon.png"cannot be resolved (ablob:URL has an opaque path), sostart_urlfalls back to the current page and relative icons are dropped. Chromium special-casesdata:manifests by resolving relative URLs against the document instead, but other engines are not required to. - Updates cannot happen. The manifest URL changes on every load (new blob) or is the entire document (data URL), and a blob URL dies with its document, so a browser checking for updates without a document has nothing to fetch.
- CSP must allow it. A
manifest-srcordefault-srcpolicy has to includedata:orblob:. The spec's security section warns that makingdata:a valid manifest source "can enable XSS attacks" and "is best avoided completely".
Serve a real, same-origin URL instead, generated by the server if necessary.
Manifests and the service worker¶
Because manifest and icon requests pass through the controlling service worker, a careless cache-first route can freeze an installed app's manifest forever. Handle the manifest explicitly with network-first and a cached fallback, so installs and launches still work offline:
const MANIFEST_CACHE = "manifest-v1";
self.addEventListener("fetch", (event) => {
const { request } = event;
// Covers <link rel="manifest"> fetches, including locale-specific variants.
if (request.destination !== "manifest") return;
event.respondWith(networkFirstManifest(request));
});
async function networkFirstManifest(request) {
const cache = await caches.open(MANIFEST_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
// Store a copy for offline launches; the original goes to the page.
await cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) return cached;
throw error; // Surface the network error when nothing is cached.
}
}
Precaching the manifest with a build tool is fine too, provided the tool revisions it by content hash so a new deployment replaces it; see Precaching.
Browser support overview¶
| Member | Chrome / Edge (desktop) | Chrome (Android) | Safari (macOS) | Safari (iOS / iPadOS) | Firefox (Android) | Samsung Internet |
|---|---|---|---|---|---|---|
name, short_name, start_url | ✅ 39 | ✅ 39 | ✅ 17 | ✅ 11.3 | ✅ 79 | ✅ 4.0 |
scope | ✅ 53 | ✅ 53 | ✅ 17 | ✅ 11.3 | ✅ 79 | ✅ 6.0 |
id | ✅ 96 | ✅ 96 | ✅ 17 | ✅ 16.4 | ❌ | ✅ 17.0 |
display | ✅ 39 | ✅ 39 | ⚠️ 171 | ⚠️ 11.31 | ✅ 47 | ✅ 4.0 |
icons | ✅ 39 | ✅ 39 | ⚠️ 172 | ⚠️ 15.42 | ✅ 79 | ✅ 4.0 |
theme_color | ✅ 46 | ✅ 46 | ✅ 17 | ✅ 15 | ✅ 79 | ✅ 5.0 |
background_color | ✅ 46 | ✅ 46 | ❌ | ❌ | ✅ 79 | ✅ 5.0 |
orientation | ⚠️3 | ✅ 39 | ❌ | ❌ | ✅ 79 | ✅ 4.0 |
shortcuts | ✅ 964 | ✅ 84 | ✅ 17.4 | ❌ | ❌ | ✅ 14.0 |
description, screenshots | ✅ 88, 1085 | ✅ 88, 945 | ❌ | ❌ | ❌ | ✅ 15.0 (description) |
display_override | ✅ 89 | ✅ 89 | ❌ | ❌ | ❌ | ✅ 15.0 |
share_target | ⚠️ 896 | ✅ 76 | ❌ | ❌ | ❌ | ✅ 12.0 |
launch_handler | ✅ 110 | ⚠️ 1107 | ❌ | ❌ | ❌ | ✅ 21.0 |
file_handlers, protocol_handlers | ✅ 102, 96 | ❌ | ❌ | ❌ | ❌ | ❌ |
*_localized | ✅ 148 | ❌ | ❌ | ❌ | ❌ | ❌ |
Edge supports each member from the same version number as Chrome, or from Edge 79 (its first Chromium-based release) for members that predate it. Firefox on desktop does not implement the manifest members in MDN's data. Since Firefox 143 (September 2025) Firefox for Windows can pin any site to the taskbar as a web app ("Taskbar Tabs"); Mozilla's architecture notes say it consults a manifest when choosing the app icon.
Support data as of September 2026. For live data see MDN's manifest reference and caniuse.com: Web App Manifest. Per-member support for every member, including the incubations, is in the members reference.
Debugging manifests¶
Chrome and Edge DevTools¶
Open Application → Manifest. The pane shows the processed manifest, which is what matters:
- Identity shows the Computed App Id, with a note when
idis missing telling you which value to add to keep the current identity. Copy that exact value intoid. - Presentation, Protocol handlers, Icons, Shortcuts and Screenshots show each processed entry. The icon section has a Show only the minimum safe area for maskable icons checkbox, and protocol handlers can be test-launched from the pane.
- Installability lists every failed check using the messages in the table above.
- Errors and warnings repeats the parser's console messages.
In the Console, filter on Manifest: to see every ignored member with its line and column. Two internal pages add history that DevTools lacks: chrome://web-app-internals (desktop) shows installed apps, their manifests and update history, and about://webapks (Android) shows each WebAPK's update status. Debug Android installs through remote debugging; details in Browser DevTools.
Safari and Firefox¶
Safari's Develop menu lists Home Screen web apps of connected devices and simulators alongside tabs, and Inspect Apps and Devices can automatically open Web Inspector for a web app's service worker (Safari 26). Firefox DevTools has an Application panel with a Manifest view that shows parsed members and icon previews.
A command-line validator¶
DevTools shows problems one page at a time. The script below applies the spec's processing rules and Chromium's installability checks in CI, with no dependencies (Node.js 18 or later):
#!/usr/bin/env node
/**
* Validate a web app manifest the way browsers process it.
*
* node scripts/check-manifest.mjs <manifest path or URL> <document URL> [deployed manifest URL]
*
* Exit code 1 means Chromium would refuse to install; warnings do not fail the run.
*/
import { readFile } from "node:fs/promises";
const JSON_MIME = /^\s*(application\/json|text\/json|[\w.+-]+\/[\w.-]+\+json)\s*(;|$)/i;
const DISPLAY_MODES = ["fullscreen", "standalone", "minimal-ui", "browser"];
const EXTENDED_MODES = ["window-controls-overlay", "tabbed", "unframed"];
const INSTALLABLE = new Set(["fullscreen", "standalone", "minimal-ui", "window-controls-overlay", "tabbed"]);
const PURPOSES = new Set(["any", "maskable", "monochrome"]);
const MIN_ICON_PX = 144; // Chromium's installability floor.
const MAX_SHORTCUTS = 10; // Chromium parses at most ten shortcuts.
const errors = [];
const warnings = [];
async function load(source, documentURL, deployedURL) {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, { redirect: "follow" });
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${source}`);
const type = response.headers.get("content-type") ?? "(none)";
if (!JSON_MIME.test(type)) {
warnings.push(`Content-Type is ${type}; the HTML standard requires a JSON MIME type.`);
}
return { text: await response.text(), manifestURL: new URL(response.url) };
}
const text = await readFile(source, "utf8");
const fileName = source.split(/[\\/]/).pop();
return { text, manifestURL: deployedURL ?? new URL(fileName, documentURL) };
}
function withinScope(target, scope) {
// Spec: same origin and a plain string-prefix match on the path.
return target.origin === scope.origin && target.pathname.startsWith(scope.pathname);
}
function resolve(value, base) {
try {
return new URL(value, base);
} catch {
return null;
}
}
function text(json, key) {
if (!(key in json)) return undefined;
if (typeof json[key] !== "string") {
warnings.push(`"${key}" ignored: expected a string, got ${typeof json[key]}.`);
return undefined;
}
const value = json[key].trim().replace(/[\r\n\t]/g, "");
return value === "" ? undefined : value;
}
function parseSizes(value) {
if (typeof value !== "string") return [];
return value.trim().split(/\s+/).flatMap((token) => {
if (token.toLowerCase() === "any") return [{ any: true, w: Infinity, h: Infinity }];
const match = /^([1-9]\d*)[xX]([1-9]\d*)$/.exec(token);
return match ? [{ any: false, w: Number(match[1]), h: Number(match[2]) }] : [];
});
}
function images(list, key, manifestURL) {
if (list === undefined) return [];
if (!Array.isArray(list)) {
warnings.push(`"${key}" ignored: expected an array.`);
return [];
}
return list.flatMap((entry, index) => {
const where = `${key}[${index}]`;
if (!entry || typeof entry !== "object" || typeof entry.src !== "string") {
warnings.push(`${where} ignored: "src" is missing.`);
return [];
}
const src = resolve(entry.src, manifestURL);
if (!src) {
warnings.push(`${where} ignored: "src" is not a valid URL.`);
return [];
}
let purposes = ["any"];
if (typeof entry.purpose === "string" && entry.purpose.trim() !== "") {
const tokens = entry.purpose.trim().split(/\s+/);
purposes = tokens.filter((token) => PURPOSES.has(token));
const unknown = tokens.filter((token) => !PURPOSES.has(token));
if (unknown.length) warnings.push(`${where}: unknown purpose ${unknown.join(", ")} (tokens are case-sensitive).`);
if (purposes.length === 0) {
warnings.push(`${where} ignored: no valid purpose.`);
return [];
}
}
const sizes = parseSizes(entry.sizes);
if (sizes.length === 0) warnings.push(`${where}: no valid "sizes"; browsers may skip it.`);
return [{ src, type: entry.type, sizes, purposes }];
});
}
function processManifest(json, manifestURL, documentURL) {
const m = { name: text(json, "name"), short_name: text(json, "short_name") };
// start_url resolves against the manifest URL and must match the document's origin.
m.start_url = new URL(documentURL);
m.explicitStart = false;
if (typeof json.start_url === "string" && json.start_url !== "") {
const url = resolve(json.start_url, manifestURL);
if (!url) warnings.push(`"start_url" ignored: not a valid URL.`);
else if (url.origin !== documentURL.origin) {
warnings.push(`"start_url" ignored: ${url.href} is not same-origin with ${documentURL.origin}.`);
} else {
m.start_url = url;
m.explicitStart = true;
}
}
// id resolves against the start URL's origin; the fragment is always removed.
m.id = new URL(m.start_url);
if (typeof json.id === "string" && json.id !== "") {
const id = resolve(json.id, m.start_url.origin);
if (!id || id.origin !== m.start_url.origin) warnings.push(`"id" ignored: must be same-origin with start_url.`);
else m.id = id;
} else {
warnings.push(`"id" missing: identity falls back to start_url (${m.start_url.href}).`);
}
m.id.hash = "";
// scope defaults to the start URL's directory; start_url must be inside it.
m.scope = new URL(".", m.start_url);
if (typeof json.scope === "string" && json.scope !== "") {
const scope = resolve(json.scope, manifestURL);
if (scope) {
scope.search = "";
scope.hash = "";
if (withinScope(m.start_url, scope)) m.scope = scope;
else warnings.push(`"scope" ignored: ${m.start_url.href} is not within ${scope.href}.`);
}
} else {
warnings.push(`"scope" missing: defaults to ${m.scope.href}.`);
}
if (!m.scope.pathname.endsWith("/")) {
warnings.push(`scope ${m.scope.pathname} lacks a trailing slash and prefix-matches sibling paths.`);
}
m.display = "browser";
if (typeof json.display === "string") {
const value = json.display.trim().toLowerCase();
if (DISPLAY_MODES.includes(value)) m.display = value;
else warnings.push(`"display" value "${json.display}" ignored${EXTENDED_MODES.includes(value) ? "; use display_override" : ""}.`);
}
m.display_override = (Array.isArray(json.display_override) ? json.display_override : [])
.filter((value) => typeof value === "string")
.map((value) => value.trim().toLowerCase())
.filter((value) => {
const known = DISPLAY_MODES.includes(value) || EXTENDED_MODES.includes(value);
if (!known) warnings.push(`display_override entry "${value}" is unknown and silently skipped.`);
return known;
});
for (const key of ["theme_color", "background_color"]) {
const value = json[key];
if (typeof value === "string" && value.trim().startsWith("#") &&
!/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value.trim())) {
warnings.push(`"${key}" ${value} is not a valid hex color and will be ignored.`);
}
}
m.icons = images(json.icons, "icons", manifestURL);
const shortcuts = Array.isArray(json.shortcuts) ? json.shortcuts : [];
if (shortcuts.length > MAX_SHORTCUTS) warnings.push(`Chromium parses only the first ${MAX_SHORTCUTS} shortcuts.`);
shortcuts.forEach((item, index) => {
const url = item && typeof item.url === "string" ? resolve(item.url, manifestURL) : null;
if (!item || typeof item.name !== "string" || item.name.trim() === "") {
warnings.push(`shortcuts[${index}] ignored: "name" must be a non-empty string.`);
} else if (!url || !withinScope(url, m.scope)) {
warnings.push(`shortcuts[${index}] ignored: "url" must be within scope ${m.scope.href}.`);
}
});
if (json.prefer_related_applications === true) m.preferRelated = true;
return m;
}
function checkChromiumInstallability(m) {
if (!m.name && !m.short_name) errors.push("Manifest does not contain a 'name' or 'short_name' field.");
if (!m.explicitStart) errors.push("Manifest start URL is not valid (Chromium needs an explicit same-origin start_url).");
const effective = m.display_override[0] ?? m.display;
if (!INSTALLABLE.has(effective)) errors.push(`Display mode "${effective}" is not installable in Chromium.`);
const anyIcons = m.icons.filter((icon) => icon.purposes.includes("any"));
const bigEnough = anyIcons.some((icon) => icon.sizes.some((s) => Math.min(s.w, s.h) >= MIN_ICON_PX));
if (!bigEnough) errors.push(`No "any" purpose icon of at least ${MIN_ICON_PX}px with "sizes" set.`);
for (const px of [192, 512]) {
if (!anyIcons.some((icon) => icon.sizes.some((s) => s.any || (s.w === px && s.h === px)))) {
warnings.push(`No ${px}x${px} "any" icon; Chrome's documentation asks for 192 and 512.`);
}
}
if (!m.icons.some((icon) => icon.purposes.includes("maskable"))) {
warnings.push("No maskable icon; adaptive-icon launchers such as Android cannot use a full-bleed icon.");
}
if (m.preferRelated) errors.push("Manifest specifies prefer_related_applications: true.");
}
async function main() {
const [source, documentArg, deployedArg] = process.argv.slice(2);
if (!source || !documentArg) {
console.error("Usage: check-manifest.mjs <manifest path|URL> <document URL> [deployed manifest URL]");
process.exit(2);
}
const documentURL = new URL(documentArg);
const { text: raw, manifestURL } = await load(source, documentURL, deployedArg ? new URL(deployedArg) : null);
let json = {};
try {
const parsed = JSON.parse(raw.replace(/^/, ""));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) json = parsed;
else errors.push("Root is not a JSON object; browsers treat the manifest as empty.");
} catch (error) {
errors.push(`Invalid JSON (${error.message}); browsers treat the manifest as empty.`);
}
const manifest = processManifest(json, manifestURL, documentURL);
checkChromiumInstallability(manifest);
console.log(`id: ${manifest.id.href}`);
console.log(`start_url: ${manifest.start_url.href}`);
console.log(`scope: ${manifest.scope.href}`);
console.log(`display: ${manifest.display_override[0] ?? manifest.display}`);
for (const message of warnings) console.warn(`warning ${message}`);
for (const message of errors) console.error(`error ${message}`);
process.exitCode = errors.length ? 1 : 0;
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 2;
});
Run it against production and against the build output before deploying, for example node scripts/check-manifest.mjs dist/app.webmanifest https://app.example.com/ https://app.example.com/app.webmanifest. For editor autocompletion, the spec points to the unofficial JSON Schema maintained at SchemaStore; browsers ignore unknown members, so a "$schema" key is harmless.
Common pitfalls¶
- Two manifest links. A framework plugin and a hand-written tag both add one; only the first is used, and it may be the stale one.
- Relative URLs in a CDN-hosted manifest.
start_urlsilently becomes the install page. Serve the manifest from the app origin. - A cookie-protected manifest without
crossorigin="use-credentials". The request arrives without cookies, the server answers401or a login page, and installation fails with a fetch or parse error. - Wrong
Content-Type.text/plainorapplication/octet-streamworks in Chromium today but violates the HTML Standard's processing rules. - Enhanced display modes in
display."display": "window-controls-overlay"is ignored and the defaultbrowserapplies; enhanced modes belong indisplay_override. - Changing
start_urlwithout anid. The computed identity changes with it, so existing users keep the old app and the browser offers a second install. - Cache-first manifest in the service worker. Installed apps never see name, color or shortcut changes.
- Scopes without a trailing slash.
/appalso captures/app-old/and/application/. - Assuming validation errors are visible. Nothing is thrown and nothing fails the page; check the console for
Manifest:messages and the DevTools Installability section.
Further reading¶
On this site
- Members reference: every member in detail
- App identity & updates:
id,start_url,scopeand the update process - Icons & maskable icons
- Installability criteria
- Installation by platform
- iOS & iPadOS
- Content Security Policy:
manifest-srcandimg-src - Manifest cheat sheet
External references
- Web Application Manifest (W3C Working Draft) and the editor's draft
- Web App Manifest – Application Information (W3C Group Note)
- Manifest Incubations (WICG)
- HTML Standard: link type "manifest"
- IANA registration for application/manifest+json
- MDN: Web application manifest
- MDN: CSP manifest-src
- web.dev: Add a web app manifest
- Chrome for Developers: A better way to update your web apps
- WebKit: Features in Safari 26.0
-
Only
standaloneandbrowserare honored as distinct modes;fullscreenandminimal-uiare not supported (MDN compatibility data). ↩↩ -
Used only when no
apple-touch-iconis present and the icon'spurposeisanyor unspecified. Safari 26 also accepts SVG icons. ↩↩ -
Parsed, but only meaningful where the app window can rotate, chiefly Android. ↩
-
Chrome and Edge 85–95 supported
shortcutson Windows only. ↩ -
Screenshots and the description appear in Chrome's richer install dialog: Chrome 94 on Android, Chrome 108 on desktop. ↩↩
-
MDN lists the member on Android, but
window.launchQueueis not available there. ↩