Chrome Extension Reading List Api — Best Practices
29 min readReading List API Patterns
Overview
The Chrome Reading List API (Chrome 120+) provides built-in reading list management. This guide covers 8 practical patterns for integrating reading list functionality into your extension.
Required Permissions
// manifest.json
{
"permissions": ["readingList"],
"optional_permissions": ["storage", "alarms", "notifications", "contextMenus", "sidePanel"]
}
// utils/feature-detection.ts
function isReadingListSupported(): boolean {
return typeof chrome !== "undefined" && "readingList" in chrome;
}
Pattern 1: Reading List API Basics
Data Model
interface ReadingListEntry {
url: string;
title: string;
hasBeenRead: boolean;
creationTime: number;
}
Adding Entries
// reading-list.ts
async function addToReadingList(url: string, title: string, hasBeenRead = false): Promise<boolean> {
try {
await chrome.readingList.addEntry({ url, title, hasBeenRead });
return true;
} catch (error) {
if ((error as Error).message.includes("already exists")) return false;
throw error;
}
}
Querying Entries
// query-reading-list.ts
async function queryReadingList(options: { hasBeenRead?: boolean } = {}): Promise<ReadingListEntry[]> {
return chrome.readingList.query(options);
}
async function getUnreadItems(): Promise<ReadingListEntry[]> {
return chrome.readingList.query({ hasBeenRead: false });
}
async function findByUrl(url: string): Promise<ReadingListEntry | null> {
const results = await chrome.readingList.query({ url });
return results[0] ?? null;
}
Updating Entries
// update-reading-list.ts
async function markAsRead(url: string): Promise<boolean> {
try {
await chrome.readingList.updateEntry({ url, hasBeenRead: true });
return true;
} catch { return false; }
}
async function updateTitle(url: string, title: string): Promise<boolean> {
try {
await chrome.readingList.updateEntry({ url, title });
return true;
} catch { return false; }
}
Removing Entries
// remove-reading-list.ts
async function removeFromReadingList(url: string): Promise<boolean> {
try {
await chrome.readingList.removeEntry({ url });
return true;
} catch { return false; }
}
async function clearReadItems(): Promise<number> {
const readItems = await chrome.readingList.query({ hasBeenRead: true });
let count = 0;
for (const item of readItems) {
if (await removeFromReadingList(item.url)) count++;
}
return count;
}
Pattern 2: Add Current Page to Reading List
Manifest
{
"action": { "default_title": "Add to Reading List" },
"permissions": ["readingList", "activeTab"]
}
Action Click Handler
// background.ts
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id || !tab.url || !tab.title) return;
if (!tab.url.startsWith("http")) return;
const existing = await chrome.readingList.query({ url: tab.url });
if (existing.length > 0) {
const entry = existing[0];
await chrome.readingList.updateEntry({ url: tab.url, hasBeenRead: !entry.hasBeenRead });
await showBadge(tab.id, entry.hasBeenRead ? "Unread" : "Read", "#4CAF50");
} else {
await chrome.readingList.addEntry({ url: tab.url, title: tab.title, hasBeenRead: false });
await showBadge(tab.id, "Saved", "#2196F3");
}
setTimeout(() => chrome.action.setBadgeText({ text: "", tabId: tab.id }), 3000);
});
async function showBadge(tabId: number, text: string, color: string): Promise<void> {
await chrome.action.setBadgeText({ text, tabId });
await chrome.action.setBadgeBackgroundColor({ color });
}
Duplicate Detection
// duplicate-check.ts
async function checkForDuplicate(url: string): Promise<boolean> {
const entries = await chrome.readingList.query({ url });
return entries.length > 0;
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url);
u.hash = "";
["utm_source", "utm_medium", "utm_campaign", "ref"].forEach(p => u.searchParams.delete(p));
return u.toString().toLowerCase();
} catch { return url.toLowerCase(); }
}
Pattern 3: Context Menu Integration
Manifest
{ "permissions": ["contextMenus", "readingList"] }
Create Context Menus
// context-menu.ts
const MENUS = {
ADD_LINK: "reading-list-add-link",
ADD_PAGE: "reading-list-add-page",
MARK_READ: "reading-list-mark-read",
REMOVE: "reading-list-remove",
} as const;
export function createReadingListMenus(): void {
chrome.contextMenus.create({ id: MENUS.ADD_LINK, contexts: ["link"], title: "Add to Reading List" });
chrome.contextMenus.create({ id: MENUS.ADD_PAGE, contexts: ["page"], title: "Add page to Reading List" });
chrome.contextMenus.create({ id: MENUS.MARK_READ, contexts: ["page", "link"], title: "Mark as Read", visible: false });
chrome.contextMenus.create({ id: MENUS.REMOVE, contexts: ["page", "link"], title: "Remove from Reading List", visible: false });
}
Handle Clicks
// context-menu-handler.ts
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const url = info.linkUrl ?? info.pageUrl ?? tab?.url;
if (!url) return;
switch (info.menuItemId) {
case MENUS.ADD_LINK:
case MENUS.ADD_PAGE:
await chrome.readingList.addEntry({ url, title: tab?.title ?? "Untitled", hasBeenRead: false }).catch(() => {});
break;
case MENUS.MARK_READ:
await chrome.readingList.updateEntry({ url, hasBeenRead: true });
break;
case MENUS.REMOVE:
await chrome.readingList.removeEntry({ url });
break;
}
});
// Show/hide based on reading list state
chrome.contextMenus.onShown.addListener(async (info, tab) => {
const url = info.linkUrl ?? info.pageUrl ?? tab?.url;
if (!url) return;
const inList = (await chrome.readingList.query({ url })).length > 0;
chrome.contextMenus.update(MENUS.MARK_READ, { visible: inList });
chrome.contextMenus.update(MENUS.REMOVE, { visible: inList });
});
Pattern 4: Reading List Dashboard
Manifest
{
"side_panel": { "default_path": "sidepanel/index.html" },
"permissions": ["readingList", "sidePanel", "storage"]
}
Dashboard Logic
// sidepanel/dashboard.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
interface ReadingListItem {
url: string;
title: string;
hasBeenRead: boolean;
creationTime: number;
}
type SortOption = "dateAdded" | "title" | "domain";
type FilterOption = "all" | "unread" | "read";
const schema = defineSchema({ sortBy: { type: "string", default: "dateAdded" }, filterBy: { type: "string", default: "all" } });
const storage = createStorage(schema);
async function fetchItems(): Promise<ReadingListItem[]> {
return chrome.readingList.query({});
}
function filterItems(items: ReadingListItem[], filter: FilterOption): ReadingListItem[] {
if (filter === "unread") return items.filter(i => !i.hasBeenRead);
if (filter === "read") return items.filter(i => i.hasBeenRead);
return items;
}
function sortItems(items: ReadingListItem[], sortBy: SortOption): ReadingListItem[] {
return [...items].sort((a, b) => {
if (sortBy === "dateAdded") return b.creationTime - a.creationTime;
if (sortBy === "title") return a.title.localeCompare(b.title);
return new URL(a.url).hostname.localeCompare(new URL(b.url).hostname);
});
}
class Dashboard {
private items: ReadingListItem[] = [];
private filter: FilterOption = "all";
private sort: SortOption = "dateAdded";
async init(): Promise<void> {
const settings = await storage.get(["filterBy", "sortBy"]);
this.filter = settings.filterBy as FilterOption;
this.sort = settings.sortBy as SortOption;
await this.refresh();
}
async refresh(): Promise<void> {
this.items = await fetchItems();
this.render();
}
private render(): void {
const filtered = filterItems(this.items, this.filter);
const sorted = sortItems(filtered, this.sort);
const unreadCount = this.items.filter(i => !i.hasBeenRead).length;
document.getElementById("app")!.innerHTML = `
<div class="header"><h2>Reading List</h2><span class="badge">${unreadCount} unread</span></div>
<div class="controls">
<button data-filter="all" class="${this.filter === "all" ? "active" : ""}">All</button>
<button data-filter="unread" class="${this.filter === "unread" ? "active" : ""}">Unread</button>
<button data-filter="read" class="${this.filter === "read" ? "active" : ""}">Read</button>
<select id="sort">
<option value="dateAdded">Date</option>
<option value="title">Title</option>
<option value="domain">Domain</option>
</select>
</div>
<div class="items">${sorted.map(item => `
<div class="item" data-url="${item.url}">
<div class="content">
<a href="${item.url}" target="_blank">${item.title}</a>
<span class="domain">${new URL(item.url).hostname}</span>
</div>
<div class="actions">
<button data-action="toggle" data-url="${item.url}">${item.hasBeenRead ? "↩" : "✓"}</button>
<button data-action="remove" data-url="${item.url}">✕</button>
</div>
</div>
`).join("")}</div>
`;
this.bindEvents();
}
private bindEvents(): void {
document.querySelectorAll("[data-filter]").forEach(btn => {
btn.addEventListener("click", async (e) => {
this.filter = (e.target as HTMLElement).dataset.filter as FilterOption;
await storage.set("filterBy", this.filter);
this.render();
});
});
document.getElementById("sort")?.addEventListener("change", async (e) => {
this.sort = (e.target as HTMLSelectElement).value as SortOption;
await storage.set("sortBy", this.sort);
this.render();
});
document.querySelectorAll("[data-action='toggle']").forEach(btn => {
btn.addEventListener("click", async (e) => {
const url = (e.target as HTMLElement).dataset.url!;
const item = this.items.find(i => i.url === url);
if (item) {
await chrome.readingList.updateEntry({ url, hasBeenRead: !item.hasBeenRead });
await this.refresh();
}
});
});
document.querySelectorAll("[data-action='remove']").forEach(btn => {
btn.addEventListener("click", async (e) => {
const url = (e.target as HTMLElement).dataset.url!;
await chrome.readingList.removeEntry({ url });
await this.refresh();
});
});
}
}
Pattern 5: Reading List Sync with External Services
Sync Manager
// sync/manager.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
interface SyncConfig { service: string; lastSync: number; enabled: boolean; }
const schema = defineSchema({ sync: { type: "object", default: { service: "", lastSync: 0, enabled: false } } });
const storage = createStorage(schema);
interface SyncResult { added: number; failed: number; }
async function syncToPocket(consumerKey: string, accessToken: string): Promise<SyncResult> {
const items = await chrome.readingList.query({});
const settings = await storage.get("sync");
let added = 0, failed = 0;
for (const item of items) {
if (item.creationTime <= settings.sync.lastSync) continue;
try {
await fetch("https://getpocket.com/v3/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ consumer_key: consumerKey, access_token: accessToken, url: item.url, title: item.title }),
});
added++;
} catch { failed++; }
}
await storage.set("sync", { ...settings.sync, lastSync: Date.now() });
return { added, failed };
}
// Alarm-based periodic sync
chrome.alarms.create("reading-list-sync", { periodInMinutes: 60 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "reading-list-sync") {
const settings = await storage.get("sync");
if (settings.sync.enabled && settings.sync.service === "pocket") {
await syncToPocket("key", "token");
}
}
});
Pattern 6: Smart Reading Suggestions
Reading Progress Tracking
// suggestions/tracker.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
interface Progress { url: string; scrollPercent: number; timeSpent: number; lastReadAt: number; }
const schema = defineSchema({ progress: { type: "object", default: {} } });
const storage = createStorage(schema);
async function updateProgress(url: string, scrollPercent: number, timeSpent: number): Promise<void> {
const p = await storage.get("progress");
p.progress[url] = { url, scrollPercent, timeSpent, lastReadAt: Date.now() };
await storage.set("progress", p);
}
async function getContinueReading(): Promise<Array<{ url: string; title: string; progress: number }>> {
const p = await storage.get("progress");
const items = await chrome.readingList.query({ hasBeenRead: false });
return items
.filter(item => p.progress[item.url]?.scrollPercent > 5)
.map(item => ({ url: item.url, title: item.title, progress: p.progress[item.url].scrollPercent }))
.sort((a, b) => b.progress - a.progress);
}
// Content script sends scroll updates
// content.ts
let maxScroll = 0;
window.addEventListener("scroll", () => {
const percent = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100);
maxScroll = Math.max(maxScroll, percent);
chrome.runtime.sendMessage({ type: "scroll-update", percent: maxScroll });
});
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "get-progress") return { percent: maxScroll };
});
Estimated Reading Time
// reading-time.ts
function estimateReadingTime(text: string): number {
return Math.ceil(text.split(/\s+/).length / 200);
}
async function getEstimatedTime(url: string): Promise<number> {
const [tab] = await chrome.tabs.query({ url });
if (!tab?.id) return 0;
const result = await chrome.tabs.sendMessage(tab.id, { type: "get-content" });
return result ? estimateReadingTime(result) : 0;
}
Pattern 7: Reading List Notifications
Badge Updates
// notifications/badge.ts
async function updateBadge(): Promise<void> {
const items = await chrome.readingList.query({ hasBeenRead: false });
const count = items.length;
if (count > 0) {
await chrome.action.setBadgeText({ text: count > 99 ? "99+" : String(count) });
await chrome.action.setBadgeBackgroundColor({ color: "#1976D2" });
} else {
await chrome.action.setBadgeText({ text: "" });
}
}
chrome.runtime.onStartup.addListener(updateBadge);
chrome.storage.onChanged.addListener(() => updateBadge());
Reminder Notifications
// notifications/reminder.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
const schema = defineSchema({ reminderEnabled: { type: "boolean", default: true }, reminderTime: { type: "string", default: "09:00" } });
const storage = createStorage(schema);
async function showReminder(): Promise<void> {
const items = await chrome.readingList.query({ hasBeenRead: false });
if (items.length === 0) return;
await chrome.notifications.create({
type: "basic",
iconUrl: chrome.runtime.getURL("icons/icon-128.png"),
title: "Reading List Reminder",
message: items.length === 1 ? "1 unread article" : `${items.length} unread articles`,
});
}
chrome.notifications.onClicked.addListener(() => chrome.sidePanel.open());
// Schedule daily reminder
function scheduleReminder(): void {
const [hours, minutes] = "09:00".split(":").map(Number);
const now = new Date();
const next = new Date(now);
next.setHours(hours, minutes, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
chrome.alarms.create("reading-reminder", { delayInMinutes: (next.getTime() - now.getTime()) / 60000, periodInMinutes: 1440 });
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "reading-reminder") {
const settings = await storage.get("reminderEnabled");
if (settings.reminderEnabled) await showReminder();
}
});
Pattern 8: Offline Reading Support
Cache Manager
// offline/cache.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";
interface CachedArticle { url: string; title: string; content: string; cachedAt: number; }
const MAX_SIZE_MB = 50;
const schema = defineSchema({ cache: { type: "object", default: {} }, cacheSize: { type: "number", default: 0 } });
const storage = createStorage(schema);
async function cacheArticle(url: string, title: string, content: string): Promise<void> {
const s = await storage.get(["cache", "cacheSize"]);
const size = new Blob([content]).size;
if (s.cacheSize + size > MAX_SIZE_MB * 1024 * 1024) await cleanup();
s.cache[url] = { url, title, content, cachedAt: Date.now() };
await storage.set({ cache: s.cache, cacheSize: s.cacheSize + size });
}
async function getCachedArticle(url: string): Promise<CachedArticle | null> {
const s = await storage.get("cache");
return s.cache[url] ?? null;
}
async function cleanup(): Promise<void> {
const s = await storage.get(["cache", "cacheSize"]);
const entries = Object.values(s.cache);
for (const entry of entries) {
const inList = await chrome.readingList.query({ url: entry.url });
if (inList.length > 0 && inList[0].hasBeenRead) {
delete s.cache[entry.url];
s.cacheSize -= new Blob([entry.content]).size;
}
}
await storage.set(s);
}
// Auto-cache when marking as read
chrome.readingList.onEntryUpdated.addListener(async (entry) => {
const url = entry.url;
const [tab] = await chrome.tabs.query({ url });
if (tab?.id) {
const result = await chrome.tabs.sendMessage(tab.id, { type: "extract-content" });
if (result) await cacheArticle(url, result.title, result.content);
}
});
Offline Detection
// offline/manager.ts
class OfflineManager {
private online = navigator.onLine;
constructor() {
window.addEventListener("online", () => { this.online = true; console.log("Online"); });
window.addEventListener("offline", () => { this.online = false; console.log("Offline"); });
}
async getContent(url: string): Promise<{ content: string; isOffline: boolean }> {
const cached = await getCachedArticle(url);
if (cached) return { content: cached.content, isOffline: !this.online };
if (!this.online) throw new Error("Offline and not cached");
return { content: "Fetch from network", isOffline: false };
}
}
Summary
| Pattern | Description | Key APIs |
|---|---|---|
| API Basics | CRUD: addEntry, query, updateEntry, removeEntry | chrome.readingList |
| Add Current Page | Action button with duplicate detection, badge | chrome.action, chrome.tabs |
| Context Menus | Right-click on links/pages | chrome.contextMenus |
| Dashboard | Side panel with filter/sort | chrome.sidePanel, chrome.storage |
| External Sync | Export to Pocket/Instapaper via alarms | chrome.alarms, fetch |
| Smart Suggestions | Track progress, continue reading | chrome.tabs.sendMessage |
| Notifications | Reminders, badge updates | chrome.notifications, chrome.alarms |
| Offline Support | Cache articles in storage | chrome.storage.local |
Key Takeaways
- URL as key: Reading List uses URLs as unique identifiers—normalize for duplicates.
- Query only: Use
query()to retrieve entries; no direct get-by-ID. - Permissions: Add
"readingList"to manifest. - Browser support: Chrome 120+, Edge 120+.
- Combine patterns: Production extensions often mix multiple patterns.
- Storage: Use
@theluckystrike/webext-storagefor settings and progress. - Messaging: Use
@theluckystrike/webext-messagingfor background-content communication. -e —
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.