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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

From bookmark tree to downloaded filegetTree returns the root node; a recursive walk produces either Netscape HTML or JSON; the text is encoded as a data URL or blob URL and saved with downloads.download.getTree()root nodewalk(node, depth)folders + linksserialiseHTML or JSONsave without a DOM in the workerencodedata: URL (worker)downloads.downloadsaveAs: truebookmarks-2026-10-02.htmluser's folder
One walk, two serialisers, one save path.

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3const seconds = (ms) => (ms ? Math.floor(ms / 1000) : "");

Execution context: a shared module. Titles come from the user and from web pages — “Q&A ” is a perfectly normal bookmark title. Escaping ampersands first prevents double-escaping. URLs go into an attribute, so quotes must be escaped too. Dates convert from milliseconds to seconds.

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.

Netscape HTML versus JSON exportComparison of the Netscape bookmark file and a JSON export on browser import support, fidelity, ease of parsing and extensibility.AspectNetscape HTMLJSONBrowser import dialogsAll major browsersNonePreserves order and nestingYesYesExtra metadata (tags, notes)Non-standard attributesAny fieldsParsing for re-importLenient HTMLJSON.parseEscaping pitfallsManyNone
Offer HTML for moving between browsers and JSON for backups and tools.

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.

Export triggered from the popupThe popup asks the worker to export as HTML; the worker reads the tree, serialises it, builds a data URL and calls downloads.download, which shows the save dialog.PopupService workerBrowser{type:'export', format:'html'}bookmarks.getTree()toNetscape + toDataUrldownloads.download({saveAs})save dialog
The worker does the work; the browser asks the user where to save.

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.
  • createObjectURL in 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.download with a data: 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 bookmarks or downloads API; bookmark export is not possible from a Safari web extension.

Verification

  1. Export HTML and open the file in a text editor: well-formed nesting, escaped titles, ten-digit ADD_DATE values.
  2. Import it into another browser’s bookmark manager and confirm folders, order and titles survive.
  3. Export JSON and validate it parses and contains every URL from getTree().
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources