DEV Community

Aakanksha
Aakanksha

Posted on

How to structure a Chrome Extension with Manifest V3 (the right way)

If you've tried building a Chrome extension recently, you've probably
hit Manifest V3 and spent an hour just figuring out why your
background page stopped working.

MV3 replaced background pages with service workers, changed how
content scripts communicate, and made permissions stricter. The
official docs are... not great. So here's the structure that
actually works.

The folder structure

chrome-extension/
├── manifest.json
├── popup/
│ ├── popup.html
│ ├── popup.css
│ └── popup.js
├── options/
│ ├── options.html
│ └── options.js
├── content/
│ └── content.js
├── background/
│ └── service-worker.js
├── utils/
│ └── storage.js
└── icons/

The manifest.json (MV3)

The biggest MV3 gotcha: background scripts are now service workers.

{
"manifest_version": 3,
"name": "Your Extension",
"version": "1.0.0",
"permissions": ["storage", "activeTab", "scripting"],
"action": {
"default_popup": "popup/popup.html"
},
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": [""],
"js": ["content/content.js"]
}
]
}

Communicating between popup and content script

This trips up almost everyone. The popup can't directly access
the page DOM — it has to message the content script.

// popup.js
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { type: 'RUN_ACTION' });

// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'RUN_ACTION') {
// do something on the page
sendResponse({ success: true });
}
return true; // keeps the channel open for async response
});

The return true at the end is critical — without it, async
responses silently fail.

Storage that syncs across devices

Use chrome.storage.sync instead of localStorage. Here's a
utility wrapper that makes it clean to use anywhere:

const Storage = {
async get(key) {
return new Promise((resolve) => {
chrome.storage.sync.get([key], (result) => resolve(result[key]));
});
},
async set(key, value) {
return new Promise((resolve) => {
chrome.storage.sync.set({ [key]: value }, resolve);
});
}
};

// Usage
await Storage.set('theme', 'dark');
const theme = await Storage.get('theme');

Service worker gotchas

Service workers in MV3 are not persistent — they sleep when idle
and wake up for events. This means:

  • Don't store state in global variables (it resets)
  • Use chrome.storage for anything that needs to persist
  • Register event listeners at the top level, not inside callbacks

Loading it in Chrome

  1. Go to chrome://extensions
  2. Enable Developer Mode (top right)
  3. Click "Load unpacked" → select your folder
  4. Done

I put all of this into a ready-to-run starter kit with a popup UI,
dark mode, options page, and all the wiring done — if you want to
skip setup and go straight to building your feature, it's on
Gumroad: https://challawar2.gumroad.com/l/chrome-extension-starter-kit

Otherwise, feel free to use the code snippets above — happy to
answer questions in the comments.

Top comments (0)