We are a small Saudi team in Riyadh, and we run 200 free browser tools in Arabic and English. People don't use a tool library the way they use a website. They come back to one tool, every day: a VAT calculator, a word counter, a PDF merger.

So we made every tool installable as its own app, with its own icon and its own window. Add the website, the forum and the tools hub, and one domain now serves a little over 200 installable apps.

The manifest part took an afternoon. The interesting part was everything around it, especially one trap we only found after launch.

One manifest per page, per language

Every tool page links its own manifest:

<link rel="manifest" href="/pwa/m/t-word-counter-en.webmanifest"> <link rel="apple-touch-icon" href="/pwa/i/t-word-counter/apple-180.png"> 

A Python build script writes all of them from the same list of tools that builds the pages, so a new tool gets its app without any manual work. Today that is 406 manifest files: one Arabic and one English for every app.

Two languages, one app: the id

Each tool has an Arabic page and an English page. If both manifests were independent, a person who installs the Arabic page and later the English one would end up with two copies of the same app. The fix is the id member:

The English and Arabic manifests of one tool share the same id but have their own start_url, scope and direction

  • id is shared. Both languages of Word Counter say "id": "/app/tool/word-counter", so the browser treats them as one app.
  • start_url and scope are per language. The English app opens /tools/en/word-counter/, the Arabic one /tools/word-counter/, each with its own dir.
  • Ids are forever. The id is how the browser recognises an installed app. Change it, and every existing install becomes a different app from the new one. We wrote ours down as permanent before the first deploy.

Icons: one drawing, four files

Each app ships any icons at 192 and 512 pixels, maskable icons at the same sizes, and a 180-pixel apple-touch-icon for iOS. The icons are drawn per category colour, so 200 apps on a phone don't look like 200 copies of the same logo.

Maskable icons are the ones people get wrong most often. The launcher, not you, decides the final shape:

The same maskable icon cut into a square, a rounded square, a squircle and a circle, with the 80% safe zone marked

Anything outside the central circle, 80% of the icon's width, can be cut off. We keep the whole drawing inside it and let the background colour run to the edges.

No service worker, on purpose

The tools already run in the browser and the pages are static, so we had nothing useful for a service worker to do. We shipped none, and Chrome offered installation anyway in our tests.

It also means one less thing that can serve a stale page after a deploy. That is a real cost on a site where a build regenerates hundreds of pages at once.

The trap: nested scopes

Here is what we found a week after launch. The website app has "scope": "/". The tools hub has "scope": "/tools/en/". Each tool has its own folder as scope. Three apps, nested inside each other:

Installing the website app with scope / makes Chrome stop offering the nested tool apps; the workaround is uninstall, install the tool, reinstall

On a device where the website app is installed, Chrome never fires beforeinstallprompt for any page inside its scope. That covers every tool on the domain. Our install button simply never appeared there.

We confirmed it by testing a nested tool page and an unrelated PWA side by side in the same browser: the tool page got no event, and the unrelated app got it at once. From Chrome's point of view this makes sense, because the page already "belongs" to an installed app. It just isn't what you want when the nested page is meant to be an app of its own.

The workaround works, and the result sticks:

  1. Uninstall the website app.
  2. Install the tool you want.
  3. Reinstall the website app.

Both then stay installed, each in its own window.

What we did about it:

  • The install card now explains this to visitors when the prompt is missing, instead of failing silently.
  • The real fix is a design choice: give the parent app a narrower scope, so it no longer covers the tools. The cost is that links from the website into the tools then open in the browser instead of the app window. We haven't made that trade yet, and it is the first thing we would decide differently from day one.

Small things that mattered

  • Mirror the button for right-to-left pages. On the Arabic pages the install button sits on one side. On the English pages it moves to the other, so it never covers the same corner as the page's other floating button.
  • Shortcuts in the parent manifest. The website app lists the tools hub and the forum as shortcuts. A long-press on the icon jumps straight there.
  • Name the app after the job. Each short name says what the tool does, because that is the only text under the icon on a phone.

Checklist if you are doing the same

  1. One id per app, shared across languages, and written down as permanent.
  2. scope and start_url per language, with the right dir.
  3. any and maskable icons, with the drawing inside the 80% circle.
  4. Decide your scopes before launch. A parent app with "scope": "/" silently swallows every app below it.
  5. Tell users why the install button is missing, rather than hiding it without a word.

Try it on any tool, for example the Word Counter, or browse all 200 tools. There is no sign-up and there are no ads.

We are Webnaa, a Saudi web design team based in Riyadh. We build websites and online stores for businesses in Saudi Arabia and the Gulf. This post was written by Claude, an AI assistant, from our live manifests and our own install tests.