Showing a First-Run Setup Page After Install
Open a welcome page on install without showing it after every update, seed defaults before the page loads, ask for optional permissions in context, and skip it on reinstall.
Table of Contents
- Root cause:
onInstalledfires for four different reasons - Step 1. Gate on reason and on stored state
- Step 2. Seed defaults idempotently
- Step 3. Make the page work when it is opened out of order
- Step 4. Ask for optional permissions here, with the reason visible
- Step 5. Handle the update case separately and quietly
- Cross-browser variation
- Verification
- FAQ
- Related
A first-run page is the highest-leverage screen an extension has: it is the one moment the user is deliberately paying attention. It is also the screen most likely to be shown at the wrong time — after every auto-update, or on a machine where the user already completed setup last month and just reinstalled. Both mistakes come from the same place, which is treating onInstalled as a single event. This page is part of Options Page Configuration and builds a setup flow that fires exactly once per person.
Root cause: onInstalled fires for four different reasons
The handler runs on a fresh install, on every version update, when the browser itself updates, and when a shared module updates. Only the first is a first run — and even that fires again if the user removes and reinstalls, which is not a first run from their point of view.
Step 1. Gate on reason and on stored state
1// sw.js — top level
2chrome.runtime.onInstalled.addListener(async (details) => {
3 if (details.reason !== "install") return;
4
5 const { setupCompletedAt } = await chrome.storage.local.get("setupCompletedAt");
6 if (setupCompletedAt) return; // reinstall over an existing profile
7
8 await seedDefaults(); // must finish before the page reads storage
9 await chrome.tabs.create({ url: chrome.runtime.getURL("setup.html"), active: true });
10});
Execution context: Service worker, listener registered during script evaluation so it is in place before the event arrives. Awaiting seedDefaults() before opening the tab removes a race that is otherwise very hard to reproduce: the page loads, reads an empty store, renders every toggle as off, and the user’s first impression is a broken settings screen. chrome.tabs.create needs no permission for an extension-scheme URL. Firefox and Safari fire onInstalled with the same reason values; Safari’s arrives when the containing app first launches the extension.
Step 2. Seed defaults idempotently
The setup page and the rest of the extension must agree on what “unset” means, and the seeding must be safe to run twice.
1// defaults.js
2export const DEFAULTS = {
3 theme: "system",
4 syncIntervalMinutes: 30,
5 notifyOnConflict: true,
6 schemaVersion: 4,
7};
8
9export async function seedDefaults() {
10 const existing = await chrome.storage.sync.get(Object.keys(DEFAULTS));
11 const missing = Object.fromEntries(
12 Object.entries(DEFAULTS).filter(([key]) => existing[key] === undefined),
13 );
14 if (Object.keys(missing).length) await chrome.storage.sync.set(missing);
15}
Execution context: Service worker. Writing only the missing keys is what makes this safe to call on every cold start as well as on install — a user who deliberately set notifyOnConflict to false must not have it reset. Seeding schemaVersion at the current value is deliberate: a fresh install has nothing to migrate, and marking it current stops the migration runner from replaying historical steps over data that never had the old shape. That interaction is covered in running data migrations on onInstalled.
Step 3. Make the page work when it is opened out of order
Users bookmark things, reload things, and open the setup page from the extensions list months later. It must not assume it is running immediately after install.
1// setup.js
2import { DEFAULTS } from "./defaults.js";
3
4const state = await chrome.storage.sync.get(Object.keys(DEFAULTS));
5const { setupCompletedAt } = await chrome.storage.local.get("setupCompletedAt");
6
7document.getElementById("returning").hidden = !setupCompletedAt;
8hydrateForm({ ...DEFAULTS, ...state });
9
10document.getElementById("finish").addEventListener("click", async () => {
11 await chrome.storage.local.set({ setupCompletedAt: Date.now() });
12 await chrome.tabs.update({ url: chrome.runtime.getURL("options.html") });
13});
Execution context: Setup page document, an ordinary extension page under the extension content security policy — the script is external and the listener is attached rather than inlined. Spreading DEFAULTS under the stored state means a key added in a later release renders correctly even for a user whose store predates it. Navigating to the options page at the end rather than closing the tab gives the user somewhere to go; closing their tab is a small hostility.
Step 4. Ask for optional permissions here, with the reason visible
The install prompt is the worst place to ask for host access. The setup page is the best: the user is reading, the feature is named, and the checkbox is next to the explanation.
1// setup.js
2document.getElementById("watch-all").addEventListener("change", async (event) => {
3 if (!event.target.checked) {
4 await chrome.scripting.unregisterContentScripts({ ids: ["watcher"] }).catch(() => {});
5 await chrome.permissions.remove({ origins: ["https://*/*"] });
6 return;
7 }
8
9 const granted = await chrome.permissions.request({ origins: ["https://*/*"] });
10 event.target.checked = granted;
11 if (!granted) return;
12
13 await chrome.scripting.registerContentScripts([{
14 id: "watcher", matches: ["https://*/*"], js: ["watch.js"], runAt: "document_idle",
15 }]);
16});
Execution context: Setup page, inside a real user gesture — permissions.request throws outside one in all three engines. The origins must also appear under optional_host_permissions in the manifest. Reverting the checkbox when the grant is declined is the detail that stops the interface lying: without it the toggle reads as on while nothing works. The wider workflow, including what reviewers expect to see, is in requesting optional permissions at runtime.
Step 5. Handle the update case separately and quietly
An update is not a first run, but a release with a genuinely new capability may deserve a note. Use a different, dismissible surface.
1// sw.js
2chrome.runtime.onInstalled.addListener(async (details) => {
3 if (details.reason !== "update") return;
4
5 const [major] = chrome.runtime.getManifest().version.split(".");
6 const [prevMajor] = (details.previousVersion ?? "0").split(".");
7 if (major === prevMajor) return; // patch or minor: say nothing
8
9 await chrome.action.setBadgeText({ text: "NEW" });
10 await chrome.storage.local.set({ whatsNewFor: chrome.runtime.getManifest().version });
11});
Execution context: Service worker. Comparing only the major component keeps routine releases silent — an extension that opens a tab on every auto-update is uninstalled quickly. A badge is passive and costs the user nothing to ignore; clear it when they next open the popup, and show the note there rather than seizing a tab. Safari never fires this path for browser-managed updates because its updates arrive with the containing app.
Cross-browser variation
- Chrome/Edge: all four
reasonvalues fire.chrome.tabs.createfor an extension URL needs no permission. The install prompt lists required permissions only, so optional ones genuinely stay out of it. - Firefox: the same reasons. Firefox also opens its own post-install panel pointing at the toolbar button, so a setup tab appears alongside it — keep the tab’s first line short so the two do not compete.
- Safari:
onInstalledfires when the extension is first enabled through the containing app. The user must also enable the extension in Safari’s settings, so the setup page should explain that step rather than assuming it happened. - All engines:
onInstalleddoes not fire on ordinary cold starts, so anything that must be true on every start belongs inonStartupor at script evaluation as well.
Verification
- Load the extension into a clean profile. The setup tab should open once, with defaults already populated.
- Reload the extension from the extensions page. No new tab should open.
- Bump the patch version and reload. Still no tab, and no badge.
- Bump the major version and reload. The badge should appear and no tab should open.
- Remove and reinstall over the same profile. Setup should be skipped because
setupCompletedAtsurvives. - Decline the permission prompt and confirm the checkbox reverts and no content script is registered.
FAQ
Should the first-run page be the options page?
Only if setup is genuinely just settings. A separate setup.html lets you explain and sequence, then hand off to options — and it can be removed later without touching the settings surface.
Can I open the popup instead of a tab?
chrome.action.openPopup exists in recent Chrome and Firefox but not reliably at install time, and a popup dismisses on the first outside click. A tab is the right surface for onboarding.
What if the user has multiple devices?
setupCompletedAt in storage.local is per device, so they will see setup once per machine. Put it in storage.sync if you would rather they see it once per account.
How do I test the install path repeatedly?
Use a fresh browser profile, or clear the key: await chrome.storage.local.remove("setupCompletedAt") and reload.
Related
- Opening the options page programmatically — where setup hands the user off.
- Running data migrations on onInstalled — the other listener sharing this event.
- Requesting optional permissions at runtime — asking in context rather than at install.
- Options Page Configuration — the guide this page belongs to.