PWA (Progressive Web App)

Vite-PWA Documentation

A Progressive Web App (PWA) is an application that combines the best of web and mobile. It installs on the home screen, works offline, and offers a smooth experience close to a native app.

This page covers the complete chain: Service Worker, manifest, front/API origins, offline startup, update and installation.

Important

Two points are sources of recurring errors and are treated in detail below: the navigation fallback (without it, the application does not restart offline) and the same-origin constraint between front and API.

Two Service Worker Modes

vite-plugin-pwa offers two strategies. SmartMaker uses both, depending on projects. You need to know which one is in place before touching the configuration, because options are not interchangeable.

Mode Who writes the Service Worker Where cache is configured
generateSW (default) the plugin, entirely workbox block in vite.config.js
injectManifest you, in mobile/src/sw.js in your sw.js, in Workbox code

Warning

In injectManifest mode, the options workbox.runtimeCaching, skipWaiting and clientsClaim placed in vite.config.js are completely ignored. They only exist in generateSW mode. This is the most frequent cause of the symptom "my VitePWA configuration doesn't work".

Reference project status:

Project Mode Manifest
smartboot (skeleton) injectManifest dynamic (SmartAuth)
smartInterventions injectManifest static (Vite)
capfullpos generateSW dynamic (SmartAuth)
offlinepropale generateSW dynamic (SmartAuth)

A project created with SmartBoot therefore starts in injectManifest mode.

generateSW Mode

// vite.config.js
VitePWA({
  registerType: 'autoUpdate',
  workbox: {
    globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
    cleanupOutdatedCaches: true,
    maximumFileSizeToCacheInBytes: 3000000,
    skipWaiting: true,
    clientsClaim: true,
    runtimeCaching: [
      {
        urlPattern: /\/api\/(home|profile)/,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'api-cache',
          expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 },
          cacheableResponse: { statuses: [0, 200] },
          networkTimeoutSeconds: 10,
        },
      },
      {
        urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'images-cache',
          expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 },
        },
      },
    ],
  },
  injectRegister: "auto",
  includeAssets: ["favicon.ico", "assets/*", "favicon.png", "apple-touch-icon.png"],
  manifest: false,
})

With appType: 'spa', the plugin automatically adds a navigateFallback to index.html. Offline restart on a client route therefore works without writing anything else.

injectManifest Mode

This is the SmartBoot skeleton mode. It is chosen because the Service Worker must carry SmartCommon's Web Push handlers, which generateSW does not allow.

// vite.config.js
VitePWA({
  registerType: 'autoUpdate',
  strategies: 'injectManifest',
  srcDir: 'src',
  filename: 'sw.js',
  injectManifest: {
    globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
    maximumFileSizeToCacheInBytes: 3000000,
  },
  // The SW is registered manually in src/main.jsx via virtual:pwa-register
  injectRegister: false,
  includeAssets: ["favicon.ico", "assets/*", "favicon.png", "apple-touch-icon.png"],
  manifest: false,
})

Key points:

  • globPatterns goes in injectManifest, not in workbox
  • injectRegister: false because registration is done manually (see below)
  • all cache behavior now lives in mobile/src/sw.js

The Service Worker in injectManifest Mode

The file mobile/src/sw.js belongs to the project. The plugin only injects the list of files to precache via self.__WB_MANIFEST.

Complete Skeleton

// mobile/src/sw.js
import {
    precacheAndRoute,
    cleanupOutdatedCaches,
    createHandlerBoundToURL,
} from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst, CacheFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { registerPushHandlers } from "@cap-rel/smartcommon/sw";

// 1. Precache build assets (mandatory in injectManifest)
precacheAndRoute(self.__WB_MANIFEST);

// 2. Equivalent of cleanupOutdatedCaches: true
cleanupOutdatedCaches();

// 3. SPA navigation fallback (see the box below)
registerRoute(new NavigationRoute(createHandlerBoundToURL("index.html"), {
    denylist: [/^\/api\//, /^\/api\.php\//, /\/[^/?]+\.[^/?]+$/],
}));

// 4. Runtime caching (equivalent of workbox.runtimeCaching)
registerRoute(
    /\/api\/(home|profile)/,
    new NetworkFirst({
        cacheName: "api-cache",
        networkTimeoutSeconds: 10,
        plugins: [
            new CacheableResponsePlugin({ statuses: [0, 200] }),
            new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 }),
        ],
    })
);

registerRoute(
    /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
    new CacheFirst({
        cacheName: "images-cache",
        plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 })],
    })
);

// 5. Equivalent of skipWaiting + clientsClaim
self.addEventListener("install", () => {
    self.skipWaiting();
});

self.addEventListener("activate", (event) => {
    event.waitUntil(self.clients.claim());
});

// 6. Web Push (shared handlers provided by smartcommon)
registerPushHandlers({
    defaultIcon: "/images/pwa-192x192.png",
    defaultBadge: "/images/pwa-64x64.png",
});

Warning

This is the number one trap of injectManifest mode. Unlike generateSW, no navigateFallback is added implicitly. The SmartBoot skeleton now provides this route; if you take over a project created before, or if you write your sw.js from scratch, verify that it is present.

Without this route, only index.html is precached, not the client paths. An offline reload on /interventions or /produit/12 finds nothing in the precache, goes to the network and fails with net::ERR_INTERNET_DISCONNECTED. Observed symptom: blank screen on offline restart, even though the Service Worker is active and the cache is full.

registerRoute(new NavigationRoute(createHandlerBoundToURL("index.html"), {
    // Never redirect API calls or file requests
    // (anything with an extension): only real application
    // navigations fall back to the shell.
    denylist: [/^\/api\//, /^\/api\.php\//, /\/[^/?]+\.[^/?]+$/],
}));

The denylist is essential: without it, the Service Worker would return index.html in response to API calls or file downloads.

Note

The two API patterns are not redundant. A PWA deployed in pwa/ is built with VITE_API_URL=/api.php/: its calls start with /api.php/, which the /^\/api\// pattern does not cover. Only the generic "anything with an extension" pattern caught them, which is fragile.

Reference implementation: smartInterventions/mobile/src/sw.js and the SmartBoot skeleton's sw.js.

Service Worker Registration

In injectManifest mode with injectRegister: false, registration is explicit in mobile/src/main.jsx:

import { registerSW } from "virtual:pwa-register";

registerSW({
  immediate: true,
});

Note

Do not leave both injectRegister: "auto" and a manual call to registerSW: the Service Worker would be registered twice.

Manifest

Two approaches coexist in SmartMaker. Choose before starting, they do not combine.

Approach Vite Configuration Link in index.html Customization
Dynamic Manifest SmartAuth manifest: false explicit, mandatory Dolibarr constants, per entity
Static Manifest Vite manifest: { ... } block generated, to be removed in code, at build time

Dynamic Manifest Served by SmartAuth

This is the choice of the SmartBoot skeleton and most modules. The manifest is produced by SmartAuth's PwaController from the Dolibarr module's constants, allowing each client to have their own name and icons without rebuilding the application.

In mobile/index.html:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="manifest" href="api.php/manifest.webmanifest">
    <link rel="icon" type="image/png" href="api.php/icon/64">
    <link rel="apple-touch-icon" href="api.php/icon/192">
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

In vite.config.js: manifest: false.

SmartAuth Routes

Route Description
GET api.php/manifest.webmanifest dynamically generated manifest JSON
GET api.php/icon/{size} PWA icon at requested size (64, 192 or 512)

Warning

The URL is indeed manifest.webmanifest, including the extension. A link to api.php/manifest returns a routing error.

These two routes are not protected: the browser must be able to load them before any authentication.

Dolibarr Constants

The prefix is the module name in uppercase.

Constant Description Default
{MODULE}_PWA_NAME full application name company name, then module name
{MODULE}_PWA_DESCRIPTION description empty
{MODULE}_PWA_BG_COLOR background color #ffffff
{MODULE}_PWA_THEME_COLOR theme color #000000

Note

There is no constant for the short name: short_name is automatically derived from the first 12 characters of {MODULE}_PWA_NAME.

Non-Configurable Fields

The dynamic manifest fixes these values and does not expose them as constants:

"id": "/",
"scope": "/",
"start_url": "/",
"display": "standalone",
"prefer_related_applications": false

Tip

The question comes up often: "display": "standalone" is already active. An application installed from a dynamic manifest therefore opens in its own window, without address bar. There's nothing to add.

This choice is assumed and will not change: the dynamic manifest only serves what truly depends on the Dolibarr instance, i.e., name, description, colors and icons. display, start_url, scope and orientation are application properties, not of the client hosting it: they belong to the build. Exposing them as constants would mean entrusting Dolibarr parameterization with decisions that only the application author can make, and multiplying the combinations to test.

If you need to modify display, start_url, orientation or add shortcuts, switch to static manifest.

Warning

id and scope should not be modified lightly. id is the key by which the browser recognizes an already installed application. Changing it makes your application a new application in the browser's eyes: existing installations are no longer recognized, and the detection described below stops working for those who already had the app installed.

Self-Declaration for Installation Detection

The dynamic manifest declares itself:

"related_applications": [
  { "platform": "webapp", "url": "https://your-app.example.fr/pwa/api.php/manifest.webmanifest" }
]

This is what makes navigator.getInstalledRelatedApps() usable: without this entry, the call returns an empty list on Android and the install banner is re-proposed to users who already have the application on their home screen.

The URL is absolute and built from the current request, never with dol_buildpath(): the application is served on its own virtualhost, which Dolibarr does not know. Since the Host header is provided by the client, it is validated; if it is unusable, related_applications is simply omitted rather than publishing a URL pointing to an origin chosen by a third party.

Nothing to do on your side: this is served automatically. If you switch to static manifest, however, this entry must be written by you.

Icons

The PwaController looks for the icon in this order:

  • Custom icon sent from module administration, in {dir_output}/pwa/icon_{size}.png
  • Icon shipped with the module, in pwa/images/pwa-{size}x{size}.png
  • Default: blue square generated with module initials (requires GD)

Served sizes are 64, 192 and 512. Any other value defaults to 512.

Static Manifest Generated by Vite

This is the smartInterventions choice. It is suitable when the manifest is the same for all clients and you want control over all fields.

// vite.config.js
VitePWA({
  // ...
  manifest: {
    name: "SmartInterventions",
    short_name: "SmartInterventions",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#dc2626",
    icons: [
      { src: 'images/pwa-64x64.png', sizes: '64x64', type: 'image/png' },
      { src: 'images/pwa-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
      { src: 'images/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
    ],
  },
})

In this case, remove the <link rel="manifest"> from index.html: Vite injects it itself. Two competing links produce an ignored or inconsistent manifest.

Front Origin and API Origin

This is the most frequently blocking question in development.

The Rule

A manifest must be served from the same origin as the HTML document that references it. A front on http://localhost:5173 and an API on https://dolibarr.local are two distinct origins: the browser refuses the manifest and displays a message like "The manifest must have the same origin as the page".

The same constraint applies to the Service Worker: it only controls its origin.

Important

It is therefore not possible to durably co-locate a front and an API on two different domains for a PWA. The solution is not to configure CORS, it is to bring both to the same origin.

In Production: Same Origin by Construction

The PWA is served from the module's pwa/ folder, on the same host as Dolibarr:

https://erp.client.fr/custom/mymodule/pwa/            <- the front
https://erp.client.fr/custom/mymodule/pwa/api.php/    <- the API

The build Makefile also forces the API URL to be relative just before building:

echo "VITE_API_URL=/api.php/" >| ./mobile/.env

then restores the original value of .env so as not to break the development environment. The production PWA therefore has no absolute backend URL.

In Development: Two Servers, Two Origins

In development, vite dev serves the front on port 5173 and the API remains on the local Dolibarr. Two origins, therefore:

  • api.php/manifest.webmanifest is not found on port 5173, since nothing serves api.php at that address
  • pointing the link to https://dolibarr.local/custom/mymodule/pwa/api.php/manifest.webmanifest causes the manifest to be rejected due to different origin

The solution is to proxy the API from the Vite development server, so that everything is served from localhost:5173.

With the SmartBoot skeleton, there is nothing to code: the proxy is already in place and only waits for an environment variable.

# mobile/.env
# Directory that CONTAINS api.php, no trailing slash
VITE_DEV_PROXY_TARGET=https://dolibarr.local/custom/mymodule/pwa

If the variable is absent or empty, no proxy is registered and the development server behavior is unchanged.

For a project not from the skeleton, the equivalent configuration:

// vite.config.js
import { defineConfig, loadEnv } from "vite";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), "");
  const proxyTarget = env.VITE_DEV_PROXY_TARGET;

  return {
    server: proxyTarget ? {
      proxy: {
        '/api.php': {
          target: proxyTarget,
          changeOrigin: true,
          secure: false,
        },
      },
    } : undefined,
    // ...
  };
});

In Shared Hosting: DoliProxy

For deployments where the PWA is not served by the client's Dolibarr, DoliProxy provides a hosted_pwa mode: the client accesses {client}.{module}.doliproxy.fr, the proxy serves the PWA static files on / and proxies /api/* to the client's Dolibarr. A single origin, so neither CORS nor manifest problems.

The PWA then reads its config.json on startup, which contains "apiUrl": "/api/". It never knows the real backend URL.

And the Preview Mode?

npm run build then npm run preview serves the build on port 4173. This is useful but partial:

What preview allows to validate What it does not allow
Service Worker registration dynamic manifest (no api.php)
precache content real API calls
offline navigation fallback icons served by SmartAuth

Complete validation is done on the PWA deployed in pwa/ and served by Dolibarr.

Starting Offline

Making an already loaded application work without a network is easy. Making it start without a network requires four conditions to be met.

1. The Bundle is Fully Precached

globPatterns must cover all file types of the build:

globPatterns: ['**/*.{js,css,html,ico,png,svg,json}']

The json extension is not decorative: it precaches translation files from public/locales/. Without them, the application starts offline but displays raw translation keys.

2. Size Limit is Sufficient

maximumFileSizeToCacheInBytes: 3000000

Warning

Any file larger than this limit is silently excluded from precache. If the main bundle exceeds the limit, the application is unusable offline and nothing signals it at build time. capfullpos encountered exactly this case and had to raise the limit to 5 MB. Check the size of your assets in pwa/assets/ and keep margin.

3. Navigation Fallback is in Place

In generateSW, it is implicit. In injectManifest, it must be written (see above). Without it, an offline reload on a route other than root gives a blank screen.

4. Business Data is in IndexedDB

The Service Worker caches files, not data. Business data must be in local database via SmartCommon's Db class and useDb<Feature> hooks. See Data Storage and Offline Synchronization.

Detect Connection State

import { useOnlineStatus } from '@cap-rel/smartcommon';

const MyComponent = () => {
  const {
    isOnline,           // true if browser is online
    isServerReachable,  // true/false/null based on health check
    lastOnline,         // timestamp of last connection
    checkNow,           // manual check
  } = useOnlineStatus({
    healthCheckUrl: '/api/health',
    healthCheckInterval: 30000,
    stabilityDelay: 2000,
    timeout: 5000,
  });

  return !isOnline ? <div>Offline mode</div> : null;
};

Synchronization

import { useSyncClient } from '@cap-rel/smartcommon';

const sync = useSyncClient({
  apiUrl: import.meta.env.VITE_API_URL,
  getAccessToken: () => api.user?.accessToken,
  scope: ['items'],
  autoSync: true,
  syncInterval: 60000,
});

await sync.create('items', { label: 'Nouveau' });
await sync.sync(); // push + pull

console.log(sync.pendingCount);  // pending operations
console.log(sync.isSyncing);     // sync in progress

See Offline Synchronization for details, including conflict resolution.

Application Update

registerType

Value Behavior
autoUpdate new Service Worker takes over without asking
prompt new Service Worker waits for explicit action

autoUpdate assumes that the Service Worker calls skipWaiting() and clients.claim(). In injectManifest mode, it's up to you to write them (section 5 of the sw.js skeleton above).

usePWAUpdate

import { usePWAUpdate } from '@cap-rel/smartcommon';

const MyApp = () => {
  const {
    updateAvailable,    // true when an update is ready
    updateActivated,    // true when the update is activated
    checkForUpdates,    // manual check
    applyUpdate,        // apply (skip waiting + reload)
    reloadPage,         // reload page
  } = usePWAUpdate({
    autoReload: false,
    checkInterval: 0,
    onUpdateAvailable: () => {},
    onUpdateActivated: () => {},
  });

  return updateAvailable ? <button onClick={applyUpdate}>Update</button> : null;
};

UpdatePrompt

Ready-to-use component. Three variants:

Variant Description
toast notification at bottom of screen (default)
banner fixed banner at top or bottom
modal centered modal window
import { UpdatePrompt } from '@cap-rel/smartcommon';

const App = () => (
  <Provider config={appConfig}>
    <UpdatePrompt
      variant="toast"
      checkInterval={60000}
      labels={{
        title: "Update available",
        message: "A new version is available.",
        reloadButton: "Refresh",
        dismissButton: "Later",
      }}
    />
    <Router />
  </Provider>
);

The Provider also accepts a pwaUpdate prop that automatically mounts UpdatePrompt. See Provider Configuration.

Display Running Version

SmartMaker convention: each build carries an incremental number, injected by the Makefile in VITE_APP_VERSION in the format <module version>.<build number> (e.g., 2.0.1.42, suffixed -dev for a debug build).

// mobile/src/utils/constants/vite.js
export const APP_VERSION = import.meta.env.VITE_APP_VERSION;

Display discreetly at the bottom of the login page, in settings and in the AboutModal. This is the first element to ask a user who reports unexpected behavior.

Update and Data Pending Synchronization

A frequently asked question: what happens to operations done offline when the Service Worker updates and the page reloads?

They survive. The queue is not in the Service Worker cache: useSyncClient persists it in a dedicated IndexedDB database (smartauth_sync), with its tables pending_changes, pending_conflicts and local_tombstones. Clearing the Service Worker cache or activating a new version does not touch it.

Warning

What destroys the queue are actions that erase site data: "Clear site data" in DevTools, deleting browser data, uninstalling the PWA. Before advising any of these manipulations to a user for troubleshooting, check sync.pendingCount.

Diagnosing a Client Stuck on an Old Version

  • DevTools, Application > Service Workers: a Service Worker in waiting state signals an update ready but not activated
  • Check that cleanupOutdatedCaches() is indeed called, otherwise old caches accumulate
  • As a last resort, Unregister then reload with cache clearing

Offering Installation

Since SmartCommon 1.0.379, the useInstallPrompt hook and InstallPrompt component handle installation detection and invitation to install.

import { InstallPrompt } from "@cap-rel/smartcommon";

<InstallPrompt
  labels={{
    title: t("installPrompt.title"),
    message: t("installPrompt.message"),
    installButton: t("installPrompt.install"),
    dismissButton: t("installPrompt.later"),
    gotItButton: t("installPrompt.gotIt"),
  }}
/>

Mount once, high in the tree, next to <UpdatePrompt /> and inside the API provider. The component renders nothing until there is something to offer.

Warning

Unlike most SmartCommon components, InstallPrompt has no translation bundle: its default labels only exist in English and locales.fr.InstallPrompt does not exist. A French-speaking application must therefore pass its own labels. The button keys are installButton, dismissButton and gotItButton.

Some realities to know:

  • On iOS, beforeinstallprompt does not exist and there is no native prompt: only manual instructions are possible
  • "session is not in standalone mode" does not mean "application is not installed"
  • navigator.getInstalledRelatedApps() only responds if the manifest declares related_applications pointing to itself. The SmartAuth dynamic manifest does this; with a static manifest, it's up to you to write it

The complete detail is in the internal documentation PWA_INSTALL.md.

Build and Deployment

# Production build
npm run build

# Preview the build
npm run preview

# The build generates:
# - dist/index.html
# - dist/assets/*.js
# - dist/assets/*.css
# - dist/sw.js (Service Worker)

Deployment in the Dolibarr module:

cd mobile
npm run build
cp -r dist/* ../pwa/

or, with the module Makefile:

make pwa

The make pwa target does more than a npm run build: it increments the build number, forces VITE_API_URL to be relative, derives the list of languages from subdirectories of public/locales/, then restores the development .env.

Verify a PWA

In DevTools

  • Application > Manifest: the manifest is loaded, no origin error, and icons display
  • Application > Service Workers: the Service Worker is activated and running
  • Application > Cache Storage: the precache contains the bundle, index.html and files from locales/

Test Offline Startup

This is the test that matters, and it must be done in this order:

  1. Load the application online, wait for Service Worker to be active
  2. Navigate to an internal page, e.g., /interventions
  3. Check Offline in the Network tab
  4. Reload the page (not just navigate)

If a blank screen appears at this step, the navigation fallback is missing.

Lighthouse

Lighthouse tab in DevTools, Progressive Web App category. Expected criteria: HTTPS (or localhost), valid manifest with icons, Service Worker registered, offline functionality, responsive design.

Known Pitfalls

Symptom Cause
"my VitePWA configuration does nothing" workbox options used in injectManifest mode
blank screen on offline reload NavigationRoute missing from sw.js
manifest not found 404 link to api.php/manifest instead of api.php/manifest.webmanifest
"manifest must have the same origin" front and API on two origins, no Vite proxy
application unusable offline without error bundle larger than maximumFileSizeToCacheInBytes
raw translation keys offline json missing from globPatterns
Service Worker registered twice injectRegister: "auto" and manual call to registerSW
manifest ignored or inconsistent <link rel="manifest"> kept with static Vite manifest

See Also