Menu

Build a Chrome Extension That Saves Text Snippets From Any Page

Build a Chrome Extension That Saves Text Snippets From Any Page

The fastest way to misunderstand Chrome extensions is to build one that only says "hello world" and calls it a day.

You learn that extensions have a manifest and a popup pretty much. Fine. But the first useful extension you build immediately asks better questions.

How does code on a web page talk to code in the extension? Where do you store data? What permission actually gives you the right to do something? Why did the service worker disappear when you looked away?

So let us build something small, but real: a snippet saver.

The extension will let you select text on any page, right-click, save the selection, and then open the extension popup to see your saved snippets. No backend. No build step. Just Manifest V3, a content script, a service worker, a context menu, and chrome.storage.local.

What You Are Building

The extension has four moving parts:

  • manifest.json tells Chrome what the extension is allowed to do.
  • content.js runs on web pages and reads the selected text.
  • background.js creates the context menu and stores snippets.
  • popup.html and popup.js show the saved snippets.

That is enough architecture to feel like a real extension without turning this into a framework tutorial.

Create a folder named snippet-saver:

snippet-saver/
  manifest.json
  background.js
  content.js
  popup.html
  popup.js
  popup.css

Start With The Manifest

Chrome's Hello World extension tutorial starts with manifest.json, and that is still the right first file. The manifest describes the extension's name, version, permissions, popup, service worker, and content scripts.

Use this:

{
  "manifest_version": 3,
  "name": "Snippet Saver",
  "description": "Save selected text from web pages into a local snippet list.",
  "version": "1.0.0",
  "permissions": ["contextMenus", "storage"],
  "host_permissions": ["<all_urls>"],
  "action": {
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

That is intentionally plain.

The contextMenus permission lets us add a right-click item. The storage permission lets us use chrome.storage.local. The content script runs on all URLs so it can read the user's current selection.

For a production extension, you would think harder about whether <all_urls> is necessary. For this first build, it keeps the moving parts understandable.

Add The Context Menu

The service worker is the extension's event handler. It does not behave like a long-running background page from the older extension model. It wakes up for events, does the work, and can be stopped later.

Create background.js:

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "save-selection",
    title: "Save selected text",
    contexts: ["selection"]
  });
});

The contexts: ["selection"] part makes the item appear when the user has selected text.

Now handle the click:

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== "save-selection") return;
  if (!tab?.id) return;

  const response = await chrome.tabs.sendMessage(tab.id, {
    type: "get-selection"
  });

  if (!response?.text) return;

  await saveSnippet({
    id: crypto.randomUUID(),
    text: response.text,
    title: response.title,
    url: response.url,
    createdAt: new Date().toISOString()
  });
});

This uses chrome.tabs.sendMessage() to ask the content script for the selected text. The service worker cannot directly inspect the page DOM. That is the content script's job.

Add the storage helper in the same file:

async function saveSnippet(snippet) {
  const { snippets = [] } = await chrome.storage.local.get("snippets");

  await chrome.storage.local.set({
    snippets: [snippet, ...snippets].slice(0, 100)
  });
}

chrome.storage.local is asynchronous and extension-specific. Use it instead of localStorage, especially because extension service workers cannot use the Web Storage API.

Read The Selection In A Content Script

Create content.js:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "get-selection") return;

  const text = window.getSelection().toString().trim();

  sendResponse({
    text,
    title: document.title,
    url: location.href
  });
});

Content scripts run in the context of web pages and can read or change the DOM. Chrome's content script docs also point out that content scripts live in an isolated world, which means your variables do not collide with the page's JavaScript.

That isolation is one of the first mental models to keep: content scripts can see the page's DOM, but they are still part of your extension.

Build The Popup

Create popup.html:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Snippet Saver</title>
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <main>
      <header>
        <h1>Saved snippets</h1>
        <button id="clear" type="button">Clear</button>
      </header>

      <p id="empty">No snippets saved yet.</p>
      <ul id="snippets"></ul>
    </main>

    <script src="popup.js"></script>
  </body>
</html>

Create popup.css:

body {
  width: 360px;
  margin: 0;
  font: 14px system-ui, sans-serif;
  color: #18151f;
  background: #f8f7fb;
}

main {
  padding: 14px;
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

h1 {
  font-size: 16px;
  margin: 0;
}

button {
  border: 1px solid #c9bfdc;
  border-radius: 6px;
  background: white;
  padding: 6px 10px;
  cursor: pointer;
}

ul {
  list-style: none;
  padding: 0;
  margin: 12px 0 0;
}

li {
  border-top: 1px solid #ded7ea;
  padding: 10px 0;
}

.text {
  margin: 0 0 6px;
  line-height: 1.35;
}

.meta {
  color: #625b70;
  font-size: 12px;
}

Create popup.js:

const list = document.querySelector("#snippets");
const empty = document.querySelector("#empty");
const clearButton = document.querySelector("#clear");

render();

clearButton.addEventListener("click", async () => {
  await chrome.storage.local.set({ snippets: [] });
  render();
});

async function render() {
  const { snippets = [] } = await chrome.storage.local.get("snippets");

  empty.hidden = snippets.length > 0;
  list.replaceChildren(
    ...snippets.map((snippet) => {
      const item = document.createElement("li");

      const text = document.createElement("p");
      text.className = "text";
      text.textContent = snippet.text;

      const meta = document.createElement("div");
      meta.className = "meta";
      meta.textContent = snippet.title || snippet.url;

      item.append(text, meta);
      return item;
    })
  );
}

Notice the boring but important detail: use textContent, not innerHTML. Snippets came from arbitrary web pages. Treat them as user data, not trusted markup.

Load The Extension Locally

Open Chrome and go to:

chrome://extensions

Then:

  1. Turn on Developer mode.
  2. Click Load unpacked.
  3. Select the snippet-saver folder.
  4. Open a normal web page.
  5. Select some text.
  6. Right-click and choose Save selected text.
  7. Click the extension icon and check the popup.

When you change manifest.json, background.js, or content.js, reload the extension from chrome://extensions. When you change popup files, reopening the popup is usually enough. Chrome's getting-started docs call out this reload distinction, and it saves a lot of fake debugging.

Common Mistakes

The first mistake is trying to read the page selection from the service worker. The service worker is not the page. Ask the content script.

The second is forgetting that content scripts need page access. A static content script with "matches": ["<all_urls>"] is easy for a tutorial, but production extensions should narrow host access when possible.

The third is storing arbitrary snippets with innerHTML. That turns a note-taking feature into an injection bug.

The fourth is assuming the background service worker is always running. Write event-driven code. Store state in chrome.storage, not in top-level variables that you expect to live forever.

The fifth is asking for permissions you do not use. Chrome's permission docs exist for a reason: permissions affect install warnings and user trust.

Where To Take It Next

Once the basic extension works, useful upgrades are obvious:

  • Add a delete button per snippet.
  • Add search in the popup.
  • Store tags with each snippet.
  • Export snippets as JSON.
  • Replace <all_urls> with narrower host permissions.
  • Add an options page for limits and formatting.
  • Sync small settings with chrome.storage.sync, while keeping snippets local.

The nice thing about this project is that every upgrade teaches a real extension concept. That is exactly what a first extension should do.

Sources

Walt is a software engineer, startup founder and previous mentor for a coding bootcamp. He has been creating software for the past 20+ years.
No comments posted yet
// Add a comment
// Color Theme

Custom accent
Pick any color
for the accent