Chrome extensions let you customize the browser in ways that go far beyond simple settings or themes. They can block ads, automate repetitive tasks, enhance your favorite sites, or reshape the browser interface entirely.
If you've ever wished a website or the browser itself behaved just a bit differently, chances are an extension already exists to do exactly that.
Learning how to build extensions opens up a space where familiar web technologies gain access to native browser capabilities. You can tap into APIs that ordinary web pages cannot use, allowing your code to run in the background, react to browser events, store data, and integrate deeply into how people work online.
And now, with built-in AI models available directly in Chrome, extensions can go even further. You can generate text on the fly, summarize content, assist users contextually, or build entirely new experiences that adapt to the user's intent.
So whether you're solving a personal frustration or just experimenting with a new distribution channel for your ideas, extensions offer an approachable yet powerful platform. And if you already know HTML, CSS, and JavaScript, you're already 95% of the way there.
In this tutorial, you'll learn the rest of the fundamentals by building a small but fully functional Chrome extension from scratch.
What you'll be building
To understand the various Chrome extensions concepts, you'll build a new tab extension that replaces the default new tab page with a random image from Unsplash.
If you've ever used popular new tab extensions like Tabliss, or Momentum, you already know how transformative this simple UI tweak can be. These extensions turn an empty tab into something visually delightful, functional, or even calming.
Your own version will:
- Fetch a random photo from Unsplash.
- Cache the image for instant loading each time a new tab opens.
- Store user preferences such as their own Unsplash access key and optional photo collections.
- Provide a simple settings interface where users can manage these options.
- Include a small popup for quick access to the settings page.
- Optionally display an AI generated motivational message using Chrome's built-in on-device model.
By the end of this guide, you'll understand how the different parts of an extension fit together: the manifest, service worker, new tab override, options page, popup, and storage.
You'll also see how to use the built-in AI APIs to add a new dimension to what an extension can offer while still keeping everything running locally and securely on the user's device.
Prerequisites
If you have experience building basic web pages, you have everything you need. But make sure you also have:
- Google Chrome (the latest version is recommended).
- A code editor such as VS Code or any editor you prefer.
Grabbing the starter files
To keep the focus on learning how extensions work rather than writing boilerplate HTML and CSS, this tutorial includes a small set of starter files. They contain the basic layout for the new tab page, the options screen, and the extension's folder structure.
You can download them from GitHub:
git clone https://github.com/freshman-tech/freshtab-starter-files.git
After cloning the repository, move into the project directory:
cd freshtab-starter-files
You can browse the files using your editor or run the tree command (if you have it installed) to get a quick overview of the structure.
tree
├── demo.png
├── icons
│ ├── 128.png
│ ├── 16.png
│ ├── 32.png
│ ├── 48.png
│ └── 64.png
├── index.html
├── js
│ ├── background.js
│ ├── constants.js
│ ├── index.js
│ ├── options.js
│ └── popup.js
├── LICENCE
├── manifest.json
├── options.html
├── package.json
├── popup.html
└── README.md
This gives you a fully prepared workspace so you can focus on the manifest, extension APIs, and the logic that brings everything together.
Anatomy of a Chrome extension
A Chrome extension is simply a collection of files (HTML, CSS, JavaScript, plus a manifest) that work together to add new capabilities to the browser. Before writing any code, it helps to understand the role each file plays in the project.
The manifest
This manifest.json file is the blueprint of your extension. It's a single file that tells Chrome what your extension is, what it does, and the permissions it needs, and other metadata.
The most important thing to know for now is that we are using Manifest V3, the modern standard for all new extensions.
Service worker
A service worker is an event-driven script that wakes up to handle an event (like the extension being installed or a message from another script), does its work, and then terminates.
In this project, the js/background.js file will act as a service worker, and react to browser events and messages to perform various actions in the background.
Popup window
When a user clicks your extension's icon in the toolbar, Chrome can display a small popup. This popup is just a regular HTML page (popup.html) with its own JavaScript and styling. Extensions often use popups to provide quick controls or shortcuts.
Override pages
Extensions can replace certain built in browser pages, such as the new tab page, history page, or bookmarks page. You can override one of these at a time by pointing Chrome to an HTML file in the manifest. In this guide, index.html replaces the default new tab page.
Extension icons
Every extension needs at least one icon so Chrome can display it in the toolbar, extensions page, and Chrome Web Store. The icons used in this tutorial live in the icons folder.
Content scripts
Content scripts are JavaScript files that run directly inside web pages. They can read or modify the page's DOM, making them ideal for features like password managers, ad blockers, and page enhancements.
While this project doesn't use content scripts, they're an important part of many extensions and good to be aware of.
Populating the manifest file
Now that you understand the structure of a Chrome extension, it's time to bring it to life by creating the manifest.json file. This is the central configuration file that Chrome reads first, and without it the browser won't recognize your project as an extension.
Open the manifest.json file in the starter project and update it with the following:
{
"manifest_version": 3,
"name": "freshtab",
"version": "1.0.0",
"description": "Experience a beautiful photo from Unsplash every time you open a new tab.",
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"48": "icons/48.png",
"64": "icons/64.png",
"128": "icons/128.png"
},
"chrome_url_overrides": {
"newtab": "index.html"
},
"action": {
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage"],
"host_permissions": ["https://api.unsplash.com/"],
"background": {
"service_worker": "js/background.js",
"type": "module"
}
}
Here's a breakdown of each field in the manifest file:
Required fields
-
manifest_version: Currently, this must always be3as v2 is now deprecated. -
name,version: The extension name and version. -
description: What the extension does. -
icons: Specifies icons for your extension in different sizes.
Optional fields
-
chrome_url_overrides: Provides a custom replacement for default browser pages (like new tab, history, or bookmarks). In this case, the new tab page is being replaced with theindex.htmlfile. -
action: Defines the appearance and behavior for the extension's toolbar icon. -
options_page: Specifies the path to the options page for your extension. -
permissions: Defines the permissions required by the extension. We only need thestoragepermission here to access the Chrome storage API. -
host_permissions: If your extension interacts with a web page or external API, you must list the specific URLs here. -
background: Registers the extension's service worker. Thetype: moduleline is necessary for the browser to recognize it as an ES module script.
With this manifest in place, Chrome now knows how to load and run your extension. Next, you'll prompt the user for an Unsplash access key so the extension can fetch images.
Prompting users for their Unsplash access key
The core functionality for this extension is fetching and displaying images from Unsplash in each new tab. The following endpoint is provided for this purpose:
https://api.unsplash.com/photos/random
It accepts a number of query parameters to narrow the pool of photos for selection. For our project, we'll use the orientation parameter to limit the results to landscape images alone:
https://api.unsplash.com/photos/random?orientation=landscape
It also requires authentication via the HTTP Authorization header. This is done by setting the Authorization header to Client-ID <ACCESS_KEY> where <ACCESS_KEY> is a valid key from Unsplash.
Follow the instructions on this page to create a free Unsplash account, and register a new application. Once your app is created, copy the Unsplash access key string in the application settings page:
This brings us to a crucial part of building browser extensions responsibly: you must not hardcode your own developer access key into the extension's code. This is insecure (anyone could steal it) and not scalable, as all users would share a single API quota.
The correct, professional approach is to have the user provide their own API key. To make this seamless, you can open the options page automatically when the extension is first installed. This gives users a clear prompt to enter their access key before the extension tries to load any images.
Add the following to your js/background.js file:
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.runtime.openOptionsPage();
}
});
This listener runs only when the extension is installed for the first time. Chrome will open the options page in a new tab, letting you add your Unsplash access key right away.
Next, wire up the logic that loads those saved values and stores new ones. Open js/options.js and add:
async function saveOptions() {
const key = document.getElementById('unsplashKey').value;
const collections = document.getElementById('collections').value;
await chrome.storage.local.set({
unsplashAccessKey: key,
collections,
});
const status = document.getElementById('status');
status.textContent = 'Options saved.';
setTimeout(() => {
status.textContent = '';
}, 1500);
}
async function restoreOptions() {
const result = await chrome.storage.local.get([
'unsplashAccessKey',
'collections',
]);
document.getElementById('unsplashKey').value = result.unsplashAccessKey || '';
document.getElementById('collections').value = result.collections || '';
}
document.addEventListener('DOMContentLoaded', restoreOptions);
document.getElementById('save').addEventListener('click', saveOptions);
This script does two things:
- Loads saved settings whenever the options page opens.
- Saves new settings to
chrome.storage.localwhen users click the Save Settings button.
We're using chrome.storage.local to save the API key and collection IDs to the local machine only. If you need to sync certain extension settings across all Chrome browsers where the user is logged in, you may use the chrome.storage.sync API instead.
The trade-off is a much smaller storage quota (around 100 KB), but that's usually enough for most settings objects.
Once these pieces are in place, load your extension in Chrome by typing chrome://extensions in the address bar and select Load unpacked after enabling Developer mode:
Navigate to the root of your project directory (where the manifest.json file lives) and select it. When the extension installs, the options page will open automatically and you can enter your Unsplash key.
You can confirm this by opening the Chrome DevTools with F12, then go to the Application tab and find the Extension storage > Local entry. You should see the empty collections field and the unsplashAccessKey key that you just added:
With the access key safely stored, the extension is ready to start fetching images in the background.
When you open a new browser tab, it should already be replaced by the one defined in your extension manifest (index.html). This page is currently blank as shown below:
You may need to turn off the Footer in recent versions of Chrome. Click Customize Chrome or go to More tools > Customize Chrome in the browser menu to fond the relevant option:
Fetching images from Unsplash
With the settings page wired up and your new tab page ready to render content, the next step is to actually load an image. That work lives in the service worker, which will:
- Read the saved API key and optional collection IDs from
chrome.storage. - Call the Unsplash API to fetch a random photo.
- Store the image response in the browser cache using the\ Cache API.
Update your js/background.js file to look like this:
// js/background.js
import { CACHE_NAME, IMAGE_KEY, METADATA_KEY } from './constants.js';
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.runtime.openOptionsPage();
}
// Always fetch a new image on install or update
fetchAndCacheImage();
});
async function fetchAndCacheImage() {
try {
const { unsplashAccessKey } = await chrome.storage.local.get(
'unsplashAccessKey'
);
if (!unsplashAccessKey) {
chrome.runtime.openOptionsPage();
return;
}
const metaResponse = await fetchPhotoMetadata(unsplashAccessKey);
const imageResponse = await fetch(metaResponse.urls.raw + '&q=85&w=2000');
if (!imageResponse.ok) {
throw new Error('Failed to fetch the image file.');
}
const cache = await caches.open(CACHE_NAME);
await cache.put(IMAGE_KEY, imageResponse);
await chrome.storage.local.set({ [METADATA_KEY]: metaResponse });
} catch (err) {
console.error('Error fetching and caching image:', err);
}
}
async function fetchPhotoMetadata(apiKey) {
const { collections } = await chrome.storage.local.get('collections');
let endpoint = 'https://api.unsplash.com/photos/random?orientation=landscape';
if (collections) {
endpoint += `&collections=${collections}`;
}
const headers = new Headers();
headers.append('Authorization', `Client-ID ${apiKey}`);
const response = await fetch(endpoint, { headers });
if (!response.ok) {
throw new Error(`Unsplash API error: ${response.statusText}`);
}
return response.json();
}
The script uses the chrome.runtime.onInstalled listener to detect when a user first installs or updates the extension. On a fresh install it opens the options page, and in all cases it calls fetchAndCacheImage() so there is at least one image ready.
The fetchAndCacheImage() function checks chrome.storage.local for the user's saved unsplashAccessKey. If the key is missing, it simply re-opens the options page and stops.
If the key is present, it calls the fetchPhotoMetadata() helper to get the photo's data (like its download URL and EXIF info). This helper builds the correct Unsplash API URL, attaches the API key to the Authorization header, and adds any custom collection IDs (if configured).
Once it has the metadata, fetchAndCacheImage() makes a second fetch call to get the actual image file and requests a resized version for better performance. It then opens the cache and uses cache.put to store the image data.
Finally, it saves the metadata object into chrome.storage.local, where all extension scripts can easily access it.
The constants referenced above are defined in js/constants.js:
// js/constants.js
export const CACHE_NAME = 'freshtab-image-cache-v1';
export const IMAGE_KEY = 'https://freshtab-app.local/next-image';
export const METADATA_KEY = 'next-image';
Why we're using the Cache API
This extension needs to store two different kinds of data:
- A small JSON object with metadata.
- A binary image file for the new tab background.
chrome.storage.local is designed for JSON serializable values like strings, numbers, or objects. It cannot store large binary data like an image Response or a Blob.
That's why the Cache API is used for the image instead. However, note that the IMAGE_KEY does not need to point to a real website. It only needs to be a valid https:// URL so the cache accepts it (otherwise you'll get an error).
If you want to store the image binary in chrome.storage.local instead, you would need to convert it to a Base64 string and add the unlimitedStorage permission, since high resolution images can easily exceed the default 10 MB quota.
Testing it out
You can now reload the extension and confirm everything is working. Find the freshtab entry in chrome://extensions and click the reload button. After a few moments, open a new tab and launch the Chrome DevTools.
In the DevTools Application panel under Extension storage → Local, you should see a next-image entry containing the metadata for the next tab's background image:
Under Cache storage, open freshtab-image-cache-v1 to see the saved image response stored under the IMAGE_KEY URL:
With the image successfully cached, you are ready to display it on the new tab page.
Loading the Unsplash image on every new tab
Now that the Unsplash metadata and image are being saved to the extension's local storage and cache, the new tab page can read them and display the photo.
The script responsible for this is js/index.js which runs every time a new tab opens. Its job is to retrieve the cached image, show it as quickly as possible, and notify the service worker to prepare the next image.
Open your js/index.js file and add the following code:
// js/index.js
import { CACHE_NAME, IMAGE_KEY } from './constants.js';
document.addEventListener('DOMContentLoaded', () => {
main();
});
async function main() {
await loadPhoto();
chrome.runtime.sendMessage({ command: 'next-image' });
}
async function loadPhoto() {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(IMAGE_KEY);
if (cachedResponse) {
const blob = await cachedResponse.blob();
document.body.style.backgroundImage = `url(${URL.createObjectURL(blob)})`;
} else {
document.body.style.backgroundColor = '#111';
}
}
When the page's HTML has loaded, the main() function is triggered. This function does two key things:
- It immediately calls
loadPhoto()to set the background image. - It then sends a
next-imagemessage to the service worker, telling it to fetch a new photo in the background so it is ready for the next tab.
The loadPhoto() function opens the browser cache and looks for an entry that matches IMAGE_KEY. If it finds one, it converts the cached response into a blob URL and applies it as the page background.
This way, a network request is avoided, making the image appear almost instantly. If nothing is cached yet (for example, on the first run), it falls back to the previous dark background color.
Before testing this, update your service worker so it listens for the next-image command:
// js/background.js
// [...]
// Listen for the "next-image" command from the new tab page
chrome.runtime.onMessage.addListener((request) => {
if (request.command === 'next-image') {
fetchAndCacheImage();
}
});
Now reload the extension from the chrome://extensions page. Open a new tab and you should be greeted with an image from Unsplash:
As you continue opening new tabs, you should see a different image each time, thanks to the next-image message the page sends and the service worker handles.
Creating a simple settings popup
To make the extension's settings easier to reach, let's add a small popup that opens whenever the user clicks the extension's toolbar icon.
The popup won't do much, but it gives users an obvious way to access the options page without digging through menus.
Here is the popup.html file:
<!DOCTYPE html>
<html>
<head>
<title>Freshtab</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css"
/>
<style>
body {
width: 200px;
padding: 1.25rem;
}
</style>
</head>
<body>
<h3 class="title is-5 has-text-centered">Freshtab</h3>
<button class="button is-primary is-fullwidth" id="options-btn">
Go to Settings
</button>
<script src="js/popup.js"></script>
</body>
</html>
The popup's JavaScript is simple. It listens for a click on the button and opens the extension's main options page:
// js/popup.js
document.addEventListener('DOMContentLoaded', () => {
const optionsButton = document.getElementById('options-btn');
if (optionsButton) {
optionsButton.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
}
});
When you click the extension icon in Chrome's toolbar, you'll see a compact window with a single button:
While users can also right-click the icon and choose Options, many people don't know that menu exists. A dedicated popup makes the settings far more discoverable and feels more polished.
If you like, you can take this further by adding a settings button directly on the new tab page as well.
Restricting images to user-defined collections
By default, the extension selects a random landscape photo from the entire Unsplash library. To make the experience more personal, you can let users provide one or more Unsplash Collection IDs. When set, the extension will pull images only from those specific collections.
This feature is already built into both the options page and the fetchPhotoMetadata() function in your background.js script, so no additional code is required.
To try it out, visit the Unsplash collections page and open any collection. The collection ID appears in the page URL, as shown below:
Copy that ID, paste it into the Custom Collection IDs field on the options page, and click Save Settings:
From now on, whenever the extension fetches an image, it will restrict the request to the specified collection. The result is a curated, more consistent set of images:
Adding motivational quotes with Chrome's Built-in AI
One of the most interesting changes in recent versions of Chrome is the addition of a built in language model that runs directly on the user's device. Through the LanguageModel API, your extension can generate text without sending data to a remote server.
But there are a few caveats.
First, this is not a universal feature; it comes with strict hardware and software requirements. The on-device AI model, Gemini Nano, requires a desktop machine with at least 16 GB of RAM if running on the CPU, or a GPU with more than 4 GB of VRAM.
It's also not available out of the box. The user must have an unmetered internet connection and at least 22 GB of free disk space for the initial model download.
And you can't just start this download automatically even if the baseline requirements are met; you have to wait for a user activation (like a button click) before you can trigger the download by creating a session.
Because of all this, you can't assume the feature will work for everyone, so it's essential to build in a graceful fallback. For this tutorial, this means checking if the model is available and simply not showing the quote if it isn't.
Let's begin by exposing a setting that lets users turn this feature on or off. In your options.html file, find and uncomment the following block:
<!-- options.html -->
<div class="field">
<label class="label">AI-powered motivational message</label>
<div class="control">
<label class="checkbox" id="quote-label">
<input type="checkbox" id="showQuote" />
Show a motivational quote on the new tab page from Chrome's built-in AI
model (Gemini Nano).
</label>
</div>
<p class="help" id="quote-help">
When enabled, a new quote will be fetched for each tab.
</p>
</div>
Since this feature depends on a browser API that may not be available everywhere, the options page needs to detect support first and update the UI accordingly.
Open js/options.js and replace its contents as follows:
// js/options.js
async function saveOptions() {
const key = document.getElementById('unsplashKey').value;
const collections = document.getElementById('collections').value;
const showQuote = document.getElementById('showQuote').checked;
await chrome.storage.local.set({
unsplashAccessKey: key,
collections,
showQuote,
});
const status = document.getElementById('status');
status.textContent = 'Options saved.';
setTimeout(() => {
status.textContent = '';
}, 1500);
}
async function restoreOptions() {
const result = await chrome.storage.local.get([
'unsplashAccessKey',
'collections',
'showQuote',
]);
document.getElementById('unsplashKey').value = result.unsplashAccessKey || '';
document.getElementById('collections').value = result.collections || '';
document.getElementById('showQuote').checked = result.showQuote ?? false;
}
async function checkAIAvailability() {
const quoteCheckbox = document.getElementById('showQuote');
const quoteLabel = document.getElementById('quote-label');
const quoteHelp = document.getElementById('quote-help');
const markUnavailable = () => {
quoteCheckbox.disabled = true;
quoteLabel.classList.add('is-disabled');
quoteHelp.textContent = 'This feature is not available in your browser';
quoteHelp.classList.add('is-danger');
};
if (!('LanguageModel' in self)) {
markUnavailable();
}
const availability = await LanguageModel.availability();
if (availability === 'available') return;
if (availability === 'unavailable') {
return markUnavailable();
}
}
document.addEventListener('DOMContentLoaded', async () => {
restoreOptions();
await checkAIAvailability();
});
document.getElementById('save').addEventListener('click', saveOptions);
Here's what this code does:
-
restoreOptions()loads any saved settings, includingshowQuote. -
saveOptions()persists the Unsplash key, collections, and the AI quote toggle. -
checkAIAvailability()callsLanguageModel.availability()and, if the feature is missing, disables the checkbox and shows a helpful message.
If the API is available, the checkbox remains enabled and users can turn the feature on as normal.
You also need to handle the case where the model is downloadable but not yet installed. In that case, you need to trigger the download (which happens in the background) and surface progress so that users know what's happening.
To implement this, extend js/options.js with:
async function saveOptions() {
// [...]
if (showQuote) {
createModelSession();
}
}
async function createModelSession() {
const progress = document.getElementById('model-progress');
const quoteHelp = document.getElementById('quote-help');
try {
quoteHelp.textContent = 'Initializing download...';
progress.classList.remove('is-hidden');
const availability = await LanguageModel.availability();
if (availability === 'available') return;
const session = await LanguageModel.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
quoteHelp.textContent = `Downloading AI model... (${Math.round(
e.loaded * 100
)}%)`;
progress.value = e.loaded;
if (e.loaded === 1) {
quoteHelp.textContent = 'Download complete, model installed';
progress.removeAttribute('value');
}
});
},
});
session.destroy();
} catch (error) {
quoteHelp.textContent = error.message;
quoteHelp.classList.add('is-danger');
console.log(error);
} finally {
progress.classList.add('is-hidden');
progress.value = 0;
}
}
When the user enables the AI feature and clicks Save Settings, createModelSession() is executed. It:
- Displays a progress bar.
- Calls
LanguageModel.create()with a monitor to track download progress. - Updates the help text as the model downloads and installs.
Once the download and installation is completed, the model will become accessible for prompting.
Next, wire the LanguageModel API into the new tab script itself. Open js/index.js and update it as follows:
// js/index.js
import { CACHE_NAME, IMAGE_KEY } from './constants.js';
document.addEventListener('DOMContentLoaded', () => {
main();
displayQuote();
});
// [...]
async function runPrompt(prompt, params) {
let session;
try {
if (!session) {
session = await LanguageModel.create(params);
}
return session.prompt(prompt);
} catch (e) {
console.log('Prompt failed');
console.error(e);
console.log('Prompt:', prompt);
session.destroy();
}
}
async function displayQuote() {
const { showQuote } = await chrome.storage.local.get('showQuote');
if (!showQuote) return;
if (!('LanguageModel' in self)) return;
const prompt =
'Write a one-sentence motivational message about success, perseverance or discipline';
const params = {
expectedInputs: [{ type: 'text', languages: ['en'] }],
expectedOutputs: [{ type: 'text', languages: ['en'] }],
temperature: 2,
topK: 128,
};
const availability = await LanguageModel.availability(params);
if (availability !== 'available') {
return;
}
const quoteText = document.getElementById('quote-text');
const quoteAuthor = document.getElementById('quote-author');
try {
quoteText.textContent = 'Loading quote...';
const response = await runPrompt(prompt, params);
quoteText.textContent = response;
quoteAuthor.classList.remove('is-hidden');
} catch (e) {
console.log(e);
quoteText.textContent = '';
}
}
This script is what brings the new AI quote feature to life on your new tab page.
The displayQuote() function checks chrome.storage.local to see if the feature is enabled. If so, it checks that the LanguageModel API exists and that the model is available with the requested parameters. It then sets a simple prompt asking for a short motivational sentence.
The expectedInputs and expectedOutputs fields specify that you want plain text in English, while temperature and topK are set to high values to encourage more varied output. For more ideas and tuning tips, check out the Prompt API documentation and prompting strategies guide
While the prompt is running, the UI shows a Loading quote... message so the user knows something is happening. When a response arrives, the quote text replaces that message and the quote author element is revealed.
After refreshing the new tab page, you should start seeing AI generated motivational messages from Gemini Nano running locally in your browser!
You can experiment with different prompts and parameters to change the tone, length, or style of the messages.
Just keep in mind that on-device AI models are not designed to handle the same level of complexity as the massive server-side models running in the cloud.
Some tips for debugging Chrome extensions
Extensions are made up of several moving parts: a service worker, popup, options page, and any overridden pages. When something breaks you need to know which piece to inspect.
Your main tool is the chrome://extensions page. Just make sure Developer mode is toggled on in the top-right corner.
To debug your service worker (background.js), find your extension card on the extensions page and click the service worker link:
Chrome opens a dedicated DevTools window where you can:
- View
console.logoutput from the service worker. - Step through code in the Sources panel.
- Inspect network calls, including Unsplash API requests.
- Inspect extension storage.
If your Chrome extension encounters a serious problem, you may also see an Errors button on the extension card:
Click it to open a panel that shows recent errors and stack traces:
After fixing the underlying issue, you can clear the errors and reload the extension.
To debug the popup, right click the extension icon in the toolbar and choose Inspect popup. This opens a DevTools window attached to the popup and keeps the popup from closing while you inspect it:
Finally, for new tab pages (or other browser overrides) you can debug them like a regular web page by right-click anywhere on the page and selecting Inspect or using the F12 shortcut key.
Resources and next steps
You've now built a full Chrome extension from scratch and gained a solid understanding of how extension parts fit together, how Manifest V3 works, and how to interact with the browser through its extension APIs.
You can see the full code in the final branch on GitHub
As you take on more advanced ideas, here are some helpful directions to explore:
Using an extensions framework
For larger or more complex projects, writing everything by hand can get cumbersome. Frameworks like Wxt and Plasmo handle the boring stuff for you such as automatic reloading, streamlining builds, and making it easier to integrate modern JavaScript tooling.
The official Chrome Extensions Samples repository is also a great place to see real, working examples of every major API.
Going deeper with Chrome's AI features
If you want to continue experimenting with the LanguageModel API, Chrome's documentation offers practical examples and prompt patterns.
With these tools and references, you can expand this project into something more interesting or use it as a launching point for entirely new ideas.
Once you're happy with your extension, you can publish it to the Chrome Web Store so others can install it. Google provides a step-by-step guide for the full submission process.
Happy building!




















Top comments (0)