Build a Reading List Chrome Extension: Save Articles for Later
In today’s information-rich internet landscape, we constantly encounter articles, tutorials, and blog posts that we want to read but don’t have time for at that moment. This is where reading list extensions become invaluable. A well-designed reading list Chrome extension allows users to save web pages for later consumption, organize them into collections, and access them across devices. In this comprehensive tutorial, we’ll walk through building a complete reading list extension using Manifest V3, the Chrome Storage API, and modern JavaScript best practices.
This project will teach you essential Chrome extension development skills including content script injection, background service workers, popup UI creation, and persistent data storage. By the end of this guide, you’ll have a fully functional extension that users can install and use immediately.
Understanding Reading List Extensions
Reading list extensions serve a specific purpose: capturing web content for later consumption. Unlike simple bookmarks, a good reading list extension captures additional metadata such as the page title, excerpt, featured image, and the time it was saved. This extra information enables a richer reading experience when users return to their saved articles.
The architecture of a reading list extension typically involves several key components working together. The popup provides a quick interface for saving the current page and viewing recent additions. Content scripts extract page metadata when the user activates the save function. The background service worker handles storage operations and potentially sync capabilities. Finally, a dedicated reading view allows users to browse and read their saved articles in a distraction-free environment.
Before we dive into implementation, let’s outline the features our reading list extension will include. We’ll build an extension that can save the current page with one click, display a list of saved articles in the popup, allow deletion of saved articles, store articles persistently using chrome.storage, and provide a clean reading interface. This feature set provides a solid foundation that you can later expand with advanced capabilities like tagging, folders, and cross-device synchronization.
Project Setup and Manifest Configuration
Every Chrome extension begins with the manifest.json file, which declares the extension’s configuration, permissions, and components. For our reading list extension, we’ll use Manifest V3, the current standard that offers improved security and performance.
Create a new folder for your extension project and add the following manifest.json:
{
"manifest_version": 3,
"name": "Reading List Saver",
"version": "1.0",
"description": "Save articles to your reading list for later. A simple and elegant read later extension.",
"permissions": [
"storage",
"activeTab",
"scripting"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
The manifest declares several important permissions. The “storage” permission enables us to save reading list data persistently using the chrome.storage API. The “activeTab” permission allows us to access information about the currently active tab when the user clicks our extension icon. The “scripting” permission permits us to execute content scripts to extract page metadata.
You’ll need to create placeholder icons for your extension to function properly. For development purposes, you can use simple colored squares or generate placeholder icons using online tools. Place these icons in an “icons” folder within your project directory.
Creating the Popup Interface
The popup is the primary user interface for our extension, appearing when users click the extension icon in the Chrome toolbar. It serves two purposes: displaying the reading list and providing a quick save button for the current page.
Create popup.html with the following structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reading List</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<header>
<h1>Reading List</h1>
<button id="saveCurrentPage" class="save-btn">Save Current Page</button>
</header>
<div id="readingList" class="reading-list">
<p class="empty-message">No articles saved yet. Click "Save Current Page" to add articles to your reading list.</p>
</div>
<footer>
<button id="clearAll" class="clear-btn">Clear All</button>
</footer>
</div>
<script src="popup.js"></script>
</body>
</html>
Now create popup.css to style the interface:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
width: 360px;
min-height: 400px;
background-color: #f9f9f9;
}
.container {
padding: 16px;
}
header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
}
h1 {
font-size: 18px;
color: #333;
margin-bottom: 12px;
}
.save-btn {
width: 100%;
padding: 10px 16px;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-btn:hover {
background-color: #3a7bc8;
}
.reading-list {
max-height: 280px;
overflow-y: auto;
}
.empty-message {
text-align: center;
color: #888;
padding: 24px 16px;
font-size: 14px;
}
.article-item {
background: white;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.article-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
.article-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #888;
}
.article-domain {
color: #4a90d9;
}
.delete-btn {
background: none;
border: none;
color: #d94a4a;
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.delete-btn:hover {
background-color: #ffebeb;
}
footer {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.clear-btn {
width: 100%;
padding: 8px;
background-color: transparent;
color: #888;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background-color: #f0f0f0;
color: #d94a4a;
border-color: #d94a4a;
}
Implementing the Popup Logic
The popup JavaScript handles user interactions, communicates with the background script, and renders the reading list. Create popup.js with the following implementation:
document.addEventListener('DOMContentLoaded', () => {
const saveButton = document.getElementById('saveCurrentPage');
const clearAllButton = document.getElementById('clearAll');
const readingListContainer = document.getElementById('readingList');
// Load and display reading list
loadReadingList();
// Save current page button handler
saveButton.addEventListener('click', async () => {
try {
// Get current tab information
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.url || tab.url.startsWith('chrome://')) {
showNotification('Cannot save this type of page');
return;
}
// Extract page content using content script
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: extractPageContent
});
const pageContent = results[0].result;
// Create article object
const article = {
id: Date.now().toString(),
url: tab.url,
title: pageContent.title || tab.title,
excerpt: pageContent.excerpt || '',
image: pageContent.image || '',
domain: new URL(tab.url).hostname,
savedAt: new Date().toISOString()
};
// Save to storage
await saveArticle(article);
// Reload the list
await loadReadingList();
showNotification('Article saved!');
} catch (error) {
console.error('Error saving article:', error);
showNotification('Failed to save article');
}
});
// Clear all button handler
clearAllButton.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear all saved articles?')) {
await chrome.storage.local.set({ readingList: [] });
loadReadingList();
showNotification('All articles cleared');
}
});
// Load reading list from storage
async function loadReadingList() {
const result = await chrome.storage.local.get('readingList');
const articles = result.readingList || [];
if (articles.length === 0) {
readingListContainer.innerHTML = '<p class="empty-message">No articles saved yet. Click "Save Current Page" to add articles to your reading list.</p>';
return;
}
// Sort by saved date, newest first
articles.sort((a, b) => new Date(b.savedAt) - new Date(a.savedAt));
readingListContainer.innerHTML = articles.map(article => `
<div class="article-item" data-url="${article.url}">
<div class="article-title">${escapeHtml(article.title)}</div>
<div class="article-meta">
<span class="article-domain">${escapeHtml(article.domain)}</span>
<button class="delete-btn" data-id="${article.id}">Delete</button>
</div>
</div>
`).join('');
// Add click handlers for article items
document.querySelectorAll('.article-item').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('delete-btn')) {
chrome.tabs.create({ url: item.dataset.url });
}
});
});
// Add click handlers for delete buttons
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteArticle(btn.dataset.id);
loadReadingList();
});
});
}
// Save article to storage
async function saveArticle(article) {
const result = await chrome.storage.local.get('readingList');
const articles = result.readingList || [];
// Check if already saved
if (articles.some(a => a.url === article.url)) {
showNotification('Article already saved');
return;
}
articles.push(article);
await chrome.storage.local.set({ readingList: articles });
}
// Delete article from storage
async function deleteArticle(articleId) {
const result = await chrome.storage.local.get('readingList');
const articles = result.readingList || [];
const filtered = articles.filter(a => a.id !== articleId);
await chrome.storage.local.set({ readingList: filtered });
}
// Extract page content (runs in context of the page)
function extractPageContent() {
const getMetaContent = (name) => {
const meta = document.querySelector(`meta[property="${name}"]`) ||
document.querySelector(`meta[name="${name}"]`);
return meta ? meta.getAttribute('content') : '';
};
return {
title: document.title,
excerpt: getMetaContent('og:description') || getMetaContent('description') || '',
image: getMetaContent('og:image') || ''
};
}
// Utility function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Show notification
function showNotification(message) {
// Simple notification - could be enhanced with a toast UI
const originalText = saveButton.textContent;
saveButton.textContent = message;
setTimeout(() => {
saveButton.textContent = originalText;
}, 2000);
}
});
This implementation handles several important functions. The save button triggers a content script that extracts the page title, description, and featured image using Open Graph and meta tags. The extracted data is stored along with the URL and timestamp in chrome.storage.local. The reading list displays saved articles sorted by date, with click handlers to open articles in new tabs and delete buttons to remove individual items.
Understanding Chrome Storage API
The chrome.storage API is specifically designed for Chrome extensions and provides several advantages over using localStorage. It offers synchronous get and set operations that return promises, automatic synchronization across browser instances when the user is signed into Chrome, and larger storage quotas compared to localStorage.
For our reading list extension, we use chrome.storage.local, which stores data locally on the device without syncing to the user’s Google account. This is appropriate for our use case since reading lists are typically personal and don’t need to be shared across devices in this basic implementation. However, you could easily extend this to use chrome.storage.sync for cross-device synchronization.
The storage operations in our code follow a simple pattern: we retrieve the existing reading list, add or remove items, and then save the updated array back to storage. While this works well for small to medium-sized lists, you might want to consider more sophisticated approaches like indexedDB for extensions with thousands of saved articles.
Content Script Injection Strategies
Our popup.js uses chrome.scripting.executeScript to run JavaScript in the context of the current page. This approach, called programatic injection, gives us access to the page’s DOM and allows us to extract metadata for our reading list entries.
The extractPageContent function demonstrates best practices for extracting page metadata. It first attempts to get Open Graph tags (og:description, og:image) which most modern websites include for social media sharing. If those aren’t available, it falls back to standard meta description tags. This dual approach ensures we capture meaningful content even when sites don’t implement Open Graph.
For more advanced metadata extraction, you might consider using the Page Metadata API or implementing custom extraction logic that parses specific site structures. Some extensions even use Readability.js, the same library that powers Firefox’s Reader View, to extract the main article content for offline reading.
Testing Your Extension
Before deploying your extension, you’ll want to test it thoroughly. Chrome provides a straightforward process for loading unpacked extensions for testing. Navigate to chrome://extensions in your browser, enable “Developer mode” in the top right corner, and click “Load unpacked” to select your extension folder.
When testing, verify that the popup opens correctly and displays the interface. Test saving articles from various types of websites, including blogs, news sites, and documentation pages. Confirm that saved articles appear in the list with correct titles and metadata. Test opening saved articles by clicking on them. Test deleting individual articles and clearing all articles. Test edge cases like trying to save chrome:// pages or duplicate articles.
Pay attention to error handling throughout your extension. Our implementation includes try-catch blocks and user-facing error messages to help diagnose issues during testing and to provide a good user experience.
Enhancing Your Reading List Extension
Once you have the basic extension working, there are many features you can add to make it more powerful and useful. Consider implementing tags or categories to organize saved articles. Add a search function to quickly find specific articles. Implement reading time estimates based on article length. Create a dedicated reading view that strips away distractions. Add support for syncing across devices using chrome.storage.sync. Implement keyboard shortcuts for quick saving. Add the ability to archive articles after reading.
Each of these enhancements provides an opportunity to learn more about Chrome extension development and create a more valuable tool for your users.
Best Practices and Considerations
When building production-ready reading list extensions, several best practices deserve attention. First, always respect user privacy by being transparent about what data you collect and how you use it. Second, implement proper error handling to prevent the extension from breaking when encountering unexpected page structures. Third, optimize your extension’s performance by lazy-loading content and minimizing DOM manipulations. Fourth, test across different Chrome versions and operating systems to ensure compatibility. Fifth, follow Chrome’s Web Store policies to avoid rejection during the review process.
Additionally, consider implementing proper TypeScript types for your code, adding comprehensive documentation, and setting up automated tests to catch regressions as you add new features.
Conclusion
Building a reading list Chrome extension is an excellent project for learning Chrome extension development fundamentals. You’ve learned how to create a Manifest V3 extension, build a popup interface with HTML and CSS, implement storage using the chrome.storage API, extract page metadata with content scripts, and handle user interactions throughout the extension.
The skills you’ve gained in this tutorial transfer directly to building other types of extensions, whether they’re productivity tools, developer utilities, or entertainment applications. The Chrome extension platform provides a rich set of APIs that enable powerful integrations with the browser and web content.
Now that you have a working foundation, consider expanding your extension with the enhancement ideas discussed above, or use this knowledge to build entirely new Chrome extensions. The possibilities are virtually unlimited, and the Chrome Web Store provides a global audience for your creations.