Exporting Bookmarks to JSON and HTML
Export the user's bookmarks from an MV3 extension: walk getTree, write the Netscape bookmark HTML format other browsers import, produce structured JSON, escape safely, and save with chrome.downloads.
Table of Contents
A bookmark manager extension needs an “Export” button: users want a backup, or want to move their bookmarks to another browser or tool. Two formats matter. The Netscape bookmark file — an odd, HTML-like format from the 1990s — is what every browser’s import dialog accepts. JSON is what other tools and your own re-import code want. Both are generated from chrome.bookmarks.getTree(), and both are easy to get subtly wrong: unescaped titles break the HTML, timestamps in the wrong unit import as 1970, and saving the file from a service worker hits the absence of URL.createObjectURL. This guide produces both files correctly. It belongs to browser data APIs for bookmarks, history and downloads.
The two formats
The Netscape format is a nested structure of <DL> lists: folders are <DT><H3> headings followed by their own <DL><p> block, and bookmarks are <DT><A HREF="…" ADD_DATE="…">Title</A> entries. ADD_DATE and LAST_MODIFIED are Unix timestamps in seconds, while chrome.bookmarks reports dateAdded in milliseconds. Browsers parse the file leniently, but titles and URLs must be HTML-escaped or a single < in a title swallows the rest of the file. JSON has no such quirks — you choose the schema — but it should preserve the tree, the ordering, and enough metadata (dates, folder names) to round-trip.
Step-by-step: export both formats
1. Read the whole tree
1// sw.js
2export async function readTree() {
3 const [root] = await chrome.bookmarks.getTree();
4 return root; // children: Bookmarks bar, Other, Mobile…
5}
Execution context: the service worker or an extension page with the bookmarks permission. The root node has no title; its children are the browser’s top-level folders, whose titles and ids differ between browsers (“Bookmarks bar” in Chrome, “Bookmarks Toolbar” in Firefox). Export them as named folders and let the importing browser place them.
2. Escape everything that goes into HTML
1const escapeHtml = (s = "") =>
2 s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
3const seconds = (ms) => (ms ? Math.floor(ms / 1000) : "");
Execution context: a shared module. Titles come from the user and from web pages — “Q&A
3. Generate the Netscape bookmark file
1export function toNetscape(root) {
2 const lines = [
3 "<!DOCTYPE NETSCAPE-Bookmark-file-1>",
4 '<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
5 "<TITLE>Bookmarks</TITLE>",
6 "<H1>Bookmarks</H1>",
7 "<DL><p>",
8 ];
9 const walk = (node, depth) => {
10 const pad = " ".repeat(depth);
11 for (const child of node.children ?? []) {
12 if (child.url) {
13 lines.push(`${pad}<DT><A HREF="${escapeHtml(child.url)}" ADD_DATE="${seconds(child.dateAdded)}">${escapeHtml(child.title)}</A>`);
14 } else if (child.children) {
15 lines.push(`${pad}<DT><H3 ADD_DATE="${seconds(child.dateAdded)}" LAST_MODIFIED="${seconds(child.dateGroupModified)}">${escapeHtml(child.title)}</H3>`);
16 lines.push(`${pad}<DL><p>`);
17 walk(child, depth + 1);
18 lines.push(`${pad}</DL><p>`);
19 }
20 }
21 };
22 walk(root, 1);
23 lines.push("</DL><p>");
24 return lines.join("\n");
25}
Execution context: a pure function, usable in the worker or a test. The odd <p> after <DL> is part of the de facto format and some importers expect it. The meta charset line matters: without it, some importers read non-ASCII titles as Latin-1. Separators (Firefox) can be exported as <HR> or skipped.
4. Generate a structured JSON export
1export function toJson(root) {
2 const map = (n) => n.url
3 ? { type: "bookmark", title: n.title, url: n.url, added: n.dateAdded ?? null }
4 : { type: "folder", title: n.title, added: n.dateAdded ?? null, children: (n.children ?? []).map(map) };
5 return JSON.stringify({
6 format: "readable-bookmarks",
7 version: 1,
8 exportedAt: new Date().toISOString(),
9 browser: navigator.userAgentData?.brands?.at(-1)?.brand ?? "unknown",
10 roots: (root.children ?? []).map(map),
11 }, null, 2);
12}
Execution context: a pure function. Ids are omitted deliberately: bookmark ids are local to a profile and meaningless elsewhere. A format and version header lets your own importer recognise and migrate the file later. Include any extension-specific metadata — tags, notes — here rather than in non-standard HTML attributes.
5. Save the file from the service worker
1function toDataUrl(text, mime) {
2 const bytes = new TextEncoder().encode(text);
3 let binary = "";
4 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
5 return `data:${mime};charset=utf-8;base64,${btoa(binary)}`;
6}
7
8export async function exportBookmarks(format) {
9 const root = await readTree();
10 const day = new Date().toISOString().slice(0, 10);
11 const [text, mime, ext] = format === "json"
12 ? [toJson(root), "application/json", "json"]
13 : [toNetscape(root), "text/html", "html"];
14 await chrome.downloads.download({ url: toDataUrl(text, mime), filename: `bookmarks-${day}.${ext}`, saveAs: true });
15}
Execution context: the service worker, which has no URL.createObjectURL, so a data: URL is the practical route. Encoding through TextEncoder and base64 preserves non-ASCII titles that a naive encodeURIComponent data URL can mangle in some engines. Chunking the String.fromCharCode call avoids argument-count limits on large exports. saveAs: true shows the save dialog so the user chooses where the backup goes. From an extension page, a Blob and URL.createObjectURL work too and handle very large files better.
6. Test the round trip
1// test: export → import into a fresh profile → compare titles and URLs
2expect(importedUrls.sort()).toEqual(originalUrls.sort());
3expect(importedTitles).toContain("Q&A <beta>");
Execution context: an end-to-end test. Import the generated HTML into a clean Chrome and Firefox profile through their import dialogs (or Playwright with a prepared profile) and compare. Include tricky titles — ampersands, angle brackets, emoji, right-to-left text — in the fixture.
7. Schedule automatic backups
1chrome.alarms.create("bookmark-backup", { periodInMinutes: 7 * 24 * 60 });
2chrome.alarms.onAlarm.addListener(async ({ name }) => {
3 if (name !== "bookmark-backup") return;
4 const root = await readTree();
5 await chrome.storage.local.set({ lastBackup: { at: Date.now(), json: toJson(root) } });
6});
Execution context: the service worker, with the alarm listener at the top level. A weekly snapshot in chrome.storage.local (request unlimitedStorage for large trees) gives users a restore point without a download dialog every week; the options page can offer “Download last backup” and “Restore from backup” using the same serialisers. Saving to disk automatically, without saveAs, is possible with chrome.downloads.download but surprises users — keep automatic backups inside the extension and let the user choose when to export a file.
Common mistakes
- Unescaped titles. A
<in a title corrupts the rest of the HTML file on import. - Milliseconds in ADD_DATE. Importers interpret it as seconds and show dates thousands of years in the future, or reject it.
- Exporting ids. They are profile-local and useless — or harmful — on import.
createObjectURLin the worker. It does not exist there; use a data URL or export from a page.- Missing charset. Non-ASCII titles turn into mojibake in some importers.
Cross-browser variation
- Chrome / Edge:
downloads.downloadwith adata:URL works from the service worker; the downloads permission is required. - Firefox: same APIs; Firefox’s top-level folders differ (“Bookmarks Menu”, “Bookmarks Toolbar”, “Other Bookmarks”) and separators appear as nodes with
type: "separator". - Safari: no
bookmarksordownloadsAPI; bookmark export is not possible from a Safari web extension.
Verification
- Export HTML and open the file in a text editor: well-formed nesting, escaped titles, ten-digit
ADD_DATEvalues. - Import it into another browser’s bookmark manager and confirm folders, order and titles survive.
- Export JSON and validate it parses and contains every URL from
getTree(). - Export a tree with several thousand bookmarks and confirm it completes without errors.
FAQ
Can I export only one folder?
Yes — call chrome.bookmarks.getSubTree(folderId) and pass that node to the serialisers.
Should exports include favicons?
The Netscape format allows an ICON attribute with a data URL, but extensions cannot read favicons from the bookmarks API. Omit them.
How do I import my JSON format back?
Walk it and call chrome.bookmarks.create for each node, parents first, ideally into a new folder so imports never overwrite existing bookmarks.
Will other tools understand my JSON?
Only your own importer, unless you adopt an existing schema. If interoperability matters more than metadata, offer the Netscape HTML file as the default export and JSON as an advanced option for backups.
Related
- Reading and writing bookmarks safely — the import side.
- Managing downloads from an extension — saving files in general.
- Exporting and importing extension settings — the same file-handling patterns for settings.
- Browser data APIs: bookmarks, history and downloads — the parent topic.