Build Your First AI-Powered Chrome Extension with Manifest V3 and OpenAI

Chrome extensions are one of the most impactful things you can build as a web developer. They live inside the browser — right where people spend most of their day — and with AI baked in, they become genuinely powerful tools.
In this tutorial, we'll build SummarizeAI, a Chrome extension that can:
- Summarize any web page with one click
- Highlight text and get an AI-generated explanation
- Store summaries locally for later reference
- Work entirely with Manifest V3 (the current standard)
Why Manifest V3? Google has fully deprecated Manifest V2 as of 2025. All new extensions must use Manifest V3, which introduces service workers instead of background pages, stricter CSP policies, and the chrome.scripting API.
Prerequisites
Before starting, make sure you have:
- Google Chrome (version 120+)
- Node.js (v18 or later) — for bundling, not required at runtime
- An OpenAI API key — get one here
- Basic knowledge of JavaScript, HTML, and CSS
- A code editor (VS Code recommended)
Project Architecture
Here's the structure we'll build:
summarize-ai/
├── manifest.json # Extension manifest (V3)
├── background.js # Service worker
├── content.js # Content script (injected into pages)
├── popup/
│ ├── popup.html # Extension popup UI
│ ├── popup.css # Popup styles
│ └── popup.js # Popup logic
├── options/
│ ├── options.html # Settings page
│ └── options.js # Settings logic
├── lib/
│ └── ai-client.js # OpenAI API wrapper
├── icons/
│ ├── icon-16.png
│ ├── icon-48.png
│ └── icon-128.png
└── README.md
Step 1: Create the Manifest
The manifest is the heart of any Chrome extension. Create manifest.json:
{
"manifest_version": 3,
"name": "SummarizeAI",
"version": "1.0.0",
"description": "AI-powered page summarizer and text explainer",
"permissions": [
"activeTab",
"storage",
"contextMenus",
"scripting"
],
"host_permissions": [
"https://api.openai.com/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": []
}
],
"options_page": "options/options.html",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}Key Manifest V3 Differences
| Feature | Manifest V2 | Manifest V3 |
|---|---|---|
| Background | Persistent page | Service worker |
| Network requests | webRequest blocking | declarativeNetRequest |
| Remote code | Allowed | Forbidden |
| Content Security | Relaxed | Strict by default |
In Manifest V3, you cannot load remote scripts. All code must be bundled with the extension. This means no CDN links — everything ships locally.
Step 2: Build the AI Client
Create lib/ai-client.js — this wraps the OpenAI API:
// lib/ai-client.js
const DEFAULT_MODEL = "gpt-4o-mini";
class AIClient {
constructor(apiKey, model = DEFAULT_MODEL) {
this.apiKey = apiKey;
this.model = model;
this.baseUrl = "https://api.openai.com/v1/chat/completions";
}
async complete(systemPrompt, userMessage, options = {}) {
const { maxTokens = 1000, temperature = 0.3 } = options;
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
max_tokens: maxTokens,
temperature,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(
`OpenAI API error: ${error.error?.message || response.statusText}`
);
}
const data = await response.json();
return data.choices[0].message.content;
}
async summarize(text) {
return this.complete(
"You are a concise summarizer. Provide a clear, structured summary of the given text. Use bullet points for key takeaways. Keep it under 200 words.",
`Summarize the following:\n\n${text}`
);
}
async explain(text) {
return this.complete(
"You are a helpful explainer. Explain the given text in simple, clear terms. If it's technical, break it down for a general audience.",
`Explain this:\n\n${text}`,
{ maxTokens: 500 }
);
}
}
// Export for use across extension contexts
if (typeof globalThis !== "undefined") {
globalThis.AIClient = AIClient;
}We use gpt-4o-mini by default because it's fast, cheap, and good enough for summarization. Users can switch to gpt-4o in the settings for higher quality.
Step 3: Set Up the Service Worker (Background Script)
The service worker handles context menus, message passing, and orchestration:
// background.js
importScripts("lib/ai-client.js");
// Create context menu on install
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "explain-selection",
title: "Explain with AI",
contexts: ["selection"],
});
chrome.contextMenus.create({
id: "summarize-page",
title: "Summarize this page",
contexts: ["page"],
});
// Set default settings
chrome.storage.sync.get(["apiKey", "model"], (result) => {
if (!result.model) {
chrome.storage.sync.set({ model: "gpt-4o-mini" });
}
});
});
// Handle context menu clicks
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const { apiKey, model } = await chrome.storage.sync.get([
"apiKey",
"model",
]);
if (!apiKey) {
chrome.notifications.create({
type: "basic",
iconUrl: "icons/icon-48.png",
title: "SummarizeAI",
message: "Please set your OpenAI API key in the extension settings.",
});
return;
}
const client = new AIClient(apiKey, model);
if (info.menuItemId === "explain-selection" && info.selectionText) {
try {
const explanation = await client.explain(info.selectionText);
// Send result to content script for display
chrome.tabs.sendMessage(tab.id, {
type: "SHOW_RESULT",
title: "AI Explanation",
content: explanation,
});
} catch (error) {
chrome.tabs.sendMessage(tab.id, {
type: "SHOW_ERROR",
message: error.message,
});
}
}
if (info.menuItemId === "summarize-page") {
// Inject script to get page content, then summarize
chrome.scripting.executeScript(
{
target: { tabId: tab.id },
func: () => document.body.innerText,
},
async (results) => {
if (results && results[0]) {
const pageText = results[0].result.substring(0, 10000);
try {
const summary = await client.summarize(pageText);
chrome.tabs.sendMessage(tab.id, {
type: "SHOW_RESULT",
title: "Page Summary",
content: summary,
});
} catch (error) {
chrome.tabs.sendMessage(tab.id, {
type: "SHOW_ERROR",
message: error.message,
});
}
}
}
);
}
});
// Handle messages from popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "SUMMARIZE_TAB") {
handleSummarizeTab(message.tabId).then(sendResponse);
return true; // Keeps the message channel open for async
}
if (message.type === "GET_HISTORY") {
chrome.storage.local.get(["history"], (result) => {
sendResponse(result.history || []);
});
return true;
}
});
async function handleSummarizeTab(tabId) {
const { apiKey, model } = await chrome.storage.sync.get([
"apiKey",
"model",
]);
if (!apiKey) return { error: "API key not set" };
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => ({
text: document.body.innerText,
title: document.title,
url: window.location.href,
}),
});
if (!results || !results[0]) return { error: "Could not read page" };
const { text, title, url } = results[0].result;
const client = new AIClient(apiKey, model);
try {
const summary = await client.summarize(text.substring(0, 10000));
// Save to history
const { history = [] } = await chrome.storage.local.get(["history"]);
history.unshift({
title,
url,
summary,
timestamp: Date.now(),
});
// Keep last 50 entries
await chrome.storage.local.set({
history: history.slice(0, 50),
});
return { summary, title };
} catch (error) {
return { error: error.message };
}
}Service workers in Manifest V3 are not persistent. They can be terminated after 30 seconds of inactivity. Don't store state in variables — always use chrome.storage.
Step 4: Build the Content Script
The content script injects a floating result panel into web pages:
// content.js
(function () {
let resultPanel = null;
function createPanel() {
if (resultPanel) return resultPanel;
resultPanel = document.createElement("div");
resultPanel.id = "summarize-ai-panel";
resultPanel.innerHTML = `
<div class="sai-header">
<span class="sai-title">SummarizeAI</span>
<button class="sai-close">×</button>
</div>
<div class="sai-body">
<div class="sai-loading" style="display:none">
<div class="sai-spinner"></div>
<span>Thinking...</span>
</div>
<div class="sai-content"></div>
</div>
`;
// Apply styles
const style = document.createElement("style");
style.textContent = `
#summarize-ai-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 380px;
max-height: 500px;
background: #1a1a2e;
color: #eee;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
overflow: hidden;
animation: sai-slide-in 0.3s ease-out;
}
@keyframes sai-slide-in {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.sai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #16213e;
border-bottom: 1px solid #0f3460;
}
.sai-title {
font-weight: 600;
color: #e94560;
}
.sai-close {
background: none;
border: none;
color: #888;
font-size: 20px;
cursor: pointer;
padding: 0 4px;
}
.sai-close:hover { color: #fff; }
.sai-body {
padding: 16px;
max-height: 400px;
overflow-y: auto;
line-height: 1.6;
}
.sai-content ul { padding-left: 20px; }
.sai-content li { margin-bottom: 6px; }
.sai-spinner {
width: 20px;
height: 20px;
border: 2px solid #333;
border-top: 2px solid #e94560;
border-radius: 50%;
animation: sai-spin 0.8s linear infinite;
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@keyframes sai-spin {
to { transform: rotate(360deg); }
}
.sai-loading {
display: flex;
align-items: center;
color: #888;
}
.sai-error {
color: #e94560;
padding: 8px;
background: rgba(233,69,96,0.1);
border-radius: 6px;
}
`;
document.head.appendChild(style);
document.body.appendChild(resultPanel);
// Close button
resultPanel.querySelector(".sai-close").addEventListener("click", () => {
resultPanel.style.display = "none";
});
return resultPanel;
}
function showLoading() {
const panel = createPanel();
panel.style.display = "block";
panel.querySelector(".sai-loading").style.display = "flex";
panel.querySelector(".sai-content").innerHTML = "";
}
function showResult(title, content) {
const panel = createPanel();
panel.style.display = "block";
panel.querySelector(".sai-loading").style.display = "none";
panel.querySelector(".sai-title").textContent = title;
// Convert markdown-like formatting
const formatted = content
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.*)/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>")
.replace(/\n/g, "<br>");
panel.querySelector(".sai-content").innerHTML = formatted;
}
function showError(message) {
const panel = createPanel();
panel.style.display = "block";
panel.querySelector(".sai-loading").style.display = "none";
panel.querySelector(".sai-content").innerHTML = `
<div class="sai-error">⚠️ ${message}</div>
`;
}
// Listen for messages from background
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "SHOW_RESULT") {
showResult(message.title, message.content);
}
if (message.type === "SHOW_ERROR") {
showError(message.message);
}
if (message.type === "SHOW_LOADING") {
showLoading();
}
});
})();Step 5: Create the Popup UI
The popup is what users see when they click the extension icon.
<!-- popup/popup.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="container">
<header>
<h1>🤖 SummarizeAI</h1>
</header>
<div id="no-key-warning" class="warning" style="display: none">
<p>⚠️ API key not set.</p>
<a href="#" id="open-settings">Open Settings</a>
</div>
<div id="main-content">
<button id="summarize-btn" class="primary-btn">
✨ Summarize This Page
</button>
<div id="result" style="display: none">
<h3 id="result-title"></h3>
<div id="result-content"></div>
</div>
<div id="loading" style="display: none">
<div class="spinner"></div>
<span>Analyzing page...</span>
</div>
<hr />
<h3>📚 Recent Summaries</h3>
<div id="history-list"></div>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>/* popup/popup.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 400px;
max-height: 550px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f23;
color: #eee;
}
.container {
padding: 16px;
}
header {
text-align: center;
margin-bottom: 16px;
}
header h1 {
font-size: 18px;
color: #e94560;
}
.primary-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #e94560, #0f3460);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.primary-btn:hover {
transform: scale(1.02);
}
.primary-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.warning {
background: rgba(233, 69, 96, 0.1);
border: 1px solid #e94560;
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
text-align: center;
}
.warning a {
color: #e94560;
}
#result {
margin-top: 16px;
padding: 12px;
background: #1a1a2e;
border-radius: 8px;
max-height: 250px;
overflow-y: auto;
line-height: 1.6;
}
#result h3 {
font-size: 14px;
color: #e94560;
margin-bottom: 8px;
}
#loading {
text-align: center;
padding: 20px;
color: #888;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #333;
border-top: 3px solid #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 8px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
hr {
border: none;
border-top: 1px solid #1a1a2e;
margin: 16px 0;
}
.history-item {
padding: 10px;
background: #1a1a2e;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.2s;
}
.history-item:hover {
background: #16213e;
}
.history-item .title {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item .date {
font-size: 11px;
color: #666;
}// popup/popup.js
document.addEventListener("DOMContentLoaded", async () => {
const summarizeBtn = document.getElementById("summarize-btn");
const resultDiv = document.getElementById("result");
const resultTitle = document.getElementById("result-title");
const resultContent = document.getElementById("result-content");
const loadingDiv = document.getElementById("loading");
const historyList = document.getElementById("history-list");
const noKeyWarning = document.getElementById("no-key-warning");
// Check for API key
const { apiKey } = await chrome.storage.sync.get(["apiKey"]);
if (!apiKey) {
noKeyWarning.style.display = "block";
summarizeBtn.disabled = true;
}
// Open settings link
document.getElementById("open-settings").addEventListener("click", (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// Summarize button
summarizeBtn.addEventListener("click", async () => {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
summarizeBtn.disabled = true;
loadingDiv.style.display = "block";
resultDiv.style.display = "none";
chrome.runtime.sendMessage(
{ type: "SUMMARIZE_TAB", tabId: tab.id },
(response) => {
loadingDiv.style.display = "none";
summarizeBtn.disabled = false;
if (response.error) {
resultTitle.textContent = "Error";
resultContent.textContent = response.error;
} else {
resultTitle.textContent = response.title;
resultContent.innerHTML = formatContent(response.summary);
}
resultDiv.style.display = "block";
loadHistory(); // Refresh history
}
);
});
// Load history
async function loadHistory() {
chrome.runtime.sendMessage({ type: "GET_HISTORY" }, (history) => {
historyList.innerHTML = history
.slice(0, 10)
.map(
(item) => `
<div class="history-item" data-url="${item.url}">
<div class="title">${item.title}</div>
<div class="date">${new Date(item.timestamp).toLocaleDateString()}</div>
</div>
`
)
.join("");
// Click to open URL
historyList.querySelectorAll(".history-item").forEach((el) => {
el.addEventListener("click", () => {
chrome.tabs.create({ url: el.dataset.url });
});
});
});
}
loadHistory();
});
function formatContent(text) {
return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.*)/gm, "<li>$1</li>")
.replace(/\n/g, "<br>");
}Step 6: Build the Options Page
<!-- options/options.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>SummarizeAI Settings</title>
<style>
body {
font-family: -apple-system, sans-serif;
max-width: 500px;
margin: 40px auto;
padding: 20px;
background: #0f0f23;
color: #eee;
}
h1 { color: #e94560; }
label { display: block; margin-top: 16px; font-weight: 600; }
input, select {
width: 100%;
padding: 10px;
margin-top: 6px;
background: #1a1a2e;
border: 1px solid #333;
border-radius: 6px;
color: #eee;
font-size: 14px;
}
button {
margin-top: 20px;
padding: 10px 24px;
background: #e94560;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.saved {
color: #4ecca3;
margin-left: 12px;
display: none;
}
</style>
</head>
<body>
<h1>⚙️ SummarizeAI Settings</h1>
<label for="apiKey">OpenAI API Key</label>
<input type="password" id="apiKey" placeholder="sk-..." />
<label for="model">Model</label>
<select id="model">
<option value="gpt-4o-mini">GPT-4o Mini (Fast & Cheap)</option>
<option value="gpt-4o">GPT-4o (Higher Quality)</option>
<option value="gpt-4.1-nano">GPT-4.1 Nano (Ultra Fast)</option>
</select>
<button id="save">Save Settings</button>
<span class="saved" id="saved-msg">✓ Saved!</span>
<script src="options.js"></script>
</body>
</html>// options/options.js
document.addEventListener("DOMContentLoaded", async () => {
const apiKeyInput = document.getElementById("apiKey");
const modelSelect = document.getElementById("model");
const saveBtn = document.getElementById("save");
const savedMsg = document.getElementById("saved-msg");
// Load existing settings
const { apiKey, model } = await chrome.storage.sync.get([
"apiKey",
"model",
]);
if (apiKey) apiKeyInput.value = apiKey;
if (model) modelSelect.value = model;
// Save
saveBtn.addEventListener("click", () => {
chrome.storage.sync.set(
{
apiKey: apiKeyInput.value.trim(),
model: modelSelect.value,
},
() => {
savedMsg.style.display = "inline";
setTimeout(() => (savedMsg.style.display = "none"), 2000);
}
);
});
});Step 7: Load and Test the Extension
Now let's load the extension in Chrome:
- Open Chrome and navigate to
chrome://extensions/ - Enable "Developer mode" using the toggle in the top-right corner
- Click "Load unpacked" and select your
summarize-ai/folder - The extension icon should appear in your toolbar
Testing Workflow
- Set your API key: Click the extension icon → notice the warning → click "Open Settings" → enter your key → save
- Summarize a page: Navigate to any article → click the extension → click "Summarize This Page"
- Explain text: Select any text on a page → right-click → "Explain with AI"
- Check history: Open the popup again — your recent summaries appear at the bottom
For debugging, open chrome://extensions/, find SummarizeAI, and click "Service Worker" to inspect the background script. Right-click the popup and choose "Inspect" to debug the popup.
Step 8: Add Keyboard Shortcuts
Make the extension faster with keyboard shortcuts. Add to manifest.json:
{
"commands": {
"summarize-page": {
"suggested_key": {
"default": "Alt+S",
"mac": "Alt+S"
},
"description": "Summarize the current page"
},
"_execute_action": {
"suggested_key": {
"default": "Alt+A",
"mac": "Alt+A"
},
"description": "Open SummarizeAI popup"
}
}
}Handle the command in background.js:
chrome.commands.onCommand.addListener(async (command) => {
if (command === "summarize-page") {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const result = await handleSummarizeTab(tab.id);
if (result.summary) {
chrome.tabs.sendMessage(tab.id, {
type: "SHOW_RESULT",
title: "Page Summary",
content: result.summary,
});
}
}
});Step 9: Handle Edge Cases
Real-world extensions need to handle failures gracefully:
// Add to background.js
// Rate limiting
const rateLimiter = {
lastCall: 0,
minInterval: 2000, // 2 seconds between calls
canProceed() {
const now = Date.now();
if (now - this.lastCall < this.minInterval) {
return false;
}
this.lastCall = now;
return true;
},
};
// Wrap API calls
async function safeApiCall(fn) {
if (!rateLimiter.canProceed()) {
return { error: "Please wait a moment before trying again." };
}
try {
return await fn();
} catch (error) {
if (error.message.includes("429")) {
return { error: "Rate limit reached. Please wait a minute." };
}
if (error.message.includes("401")) {
return { error: "Invalid API key. Check your settings." };
}
return { error: `Unexpected error: ${error.message}` };
}
}Step 10: Prepare for the Chrome Web Store
When you're ready to publish:
1. Create Icons
You'll need icons at 16×16, 48×48, and 128×128 pixels in PNG format. Use a tool like Figma or even an AI image generator.
2. Build a ZIP
# Remove any development files
zip -r summarize-ai.zip summarize-ai/ \
-x "*.DS_Store" \
-x "*node_modules*" \
-x "*.git*"3. Submit to Chrome Web Store
- Go to the Chrome Web Store Developer Dashboard
- Pay the one-time $5 registration fee
- Click "New Item" and upload your ZIP
- Fill out the listing: description, screenshots, category
- Submit for review (typically 1-3 business days)
Privacy Policy Required: If your extension sends data to external APIs (like OpenAI), you MUST provide a privacy policy URL. This is enforced by the Chrome Web Store.
Going Further
Here are ideas to extend SummarizeAI:
- Multiple Languages: Detect page language and summarize in the user's preferred language
- Custom Prompts: Let users define their own system prompts for specialized summarization
- PDF Support: Use
pdf.jsto extract text from PDF files - Export: Save summaries as Markdown or send to Notion
- Streaming: Use OpenAI's streaming API for real-time response display
- Local AI: Integrate with Ollama for completely offline summarization
Summary
In this tutorial, you built a fully functional AI-powered Chrome extension using Manifest V3. Here's what we covered:
- ✅ Manifest V3 structure and key differences from V2
- ✅ Service workers for background processing
- ✅ Content scripts for injecting UI into web pages
- ✅ OpenAI API integration for summarization and explanation
- ✅ Popup UI with history tracking
- ✅ Context menus and keyboard shortcuts
- ✅ Error handling and rate limiting
- ✅ Chrome Web Store publishing process
The complete source code is available on GitHub. Chrome extensions are a fantastic way to deliver AI directly where users need it — inside their browser. Now go build something amazing. 🚀
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces
A comprehensive guide to integrating AI chatbots into your applications using OpenAI, Anthropic Claude, and ElevenLabs. Learn to build text and voice-enabled chatbots with Next.js.

AI SDK Tutorial Hub: Your Complete Guide to Building AI Applications
Your comprehensive guide to AI SDKs and tools. Find tutorials organized by difficulty covering Vercel AI SDK, ModelFusion, OpenAI, Anthropic, and more.

Building an AI-Powered SQL Analysis Tool
Step-by-step guide to build an AI-powered app for SQL analysis using natural language.