Guides · Content pipelines · Published 2026-09-12 · 2 min read

Build an EPUB from Markdown with Node.js — EPUB 3 Structure, the Uncompressed mimetype Rule, XHTML Conversion, a Navigation Document, and a Cover Image

Generate a valid EPUB 3 from a folder of Markdown chapters using marked and jszip: the required files, why mimetype must be stored uncompressed and first, converting HTML to XHTML, the nav.xhtml table of contents, content.opf metadata, and adding a cover.

An EPUB is a zip file with a strict layout. Once you know the five things it must contain, generating one from Markdown is a short script, and the result opens in Kindle Previewer, Apple Books, Google Play Books and every store uploader we have tried.

What an EPUB 3 must contain

File Purpose
mimetype The literal text application/epub+zip, stored uncompressed and as the first entry
META-INF/container.xml Points to the package file
OEBPS/content.opf Metadata, manifest of every file, and the reading order (spine)
OEBPS/nav.xhtml The table of contents, an XHTML document with epub:type="toc"
OEBPS/*.xhtml One XHTML file per chapter, plus optional cover and title pages

Readers reject a file where mimetype is compressed or not first, so handle that entry explicitly.

Markdown to XHTML

marked produces HTML, but EPUB wants well-formed XHTML. For typical prose output the differences are small: self-close void elements and escape bare ampersands.

const toX = (html) => html
  .replace(/<(br|hr|img)([^>]*?)(?<!\/)>/g, '<$1$2/>')
  .replace(/&(?!(amp|lt|gt|quot|apos|#\d+);)/g, '&amp;');

Wrap each chapter:

const xhtml = (title, body) => `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${lang}">
<head><meta charset="utf-8"/><title>${esc(title)}</title><link rel="stylesheet" href="style.css"/></head>
<body>${body}</body></html>`;

Assemble with jszip

const JSZip = require('jszip');
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`);
const o = zip.folder('OEBPS');
o.file('style.css', css);
o.file('cover.jpg', coverBuffer);
o.file('cover.xhtml', xhtml('Cover', '<div style="text-align:center"><img src="cover.jpg" alt="Cover"/></div>'));
chapters.forEach((c) => o.file(`${c.id}.xhtml`, xhtml(c.title, toX(`<h1>${esc(c.title)}</h1>${c.html}`))));

Because mimetype is added first with STORE, it satisfies the ordering rule as long as you do not add anything before it.

The navigation document

o.file('nav.xhtml', xhtml('Contents', `<nav epub:type="toc" id="toc"><h1>Contents</h1><ol>
${chapters.map((c) => `<li><a href="${c.id}.xhtml">${esc(c.title)}</a></li>`).join('\n')}
</ol></nav>`));

Keep the label language consistent with the book: an English book with a Thai "สารบัญ" heading is the kind of thing a store previewer shows and a reader notices.

The package file

o.file('content.opf', `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid" xml:lang="${lang}">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:identifier id="uid">urn:uuid:${uuid}</dc:identifier>
    <dc:title>${esc(title)}</dc:title>
    <dc:creator>${esc(author)}</dc:creator>
    <dc:language>${lang}</dc:language>
    <meta property="dcterms:modified">${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}</meta>
  </metadata>
  <manifest>
    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
    <item id="css" href="style.css" media-type="text/css"/>
    <item id="cover-image" href="cover.jpg" media-type="image/jpeg" properties="cover-image"/>
    <item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
    ${chapters.map((c) => `<item id="${c.id}" href="${c.id}.xhtml" media-type="application/xhtml+xml"/>`).join('\n    ')}
  </manifest>
  <spine>
    <itemref idref="cover"/><itemref idref="nav"/>
    ${chapters.map((c) => `<itemref idref="${c.id}"/>`).join('')}
  </spine>
</package>`);
const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', mimeType: 'application/epub+zip' });
fs.writeFileSync('book.epub', buf);

dcterms:modified must be a second-precision UTC timestamp without fractions; validators complain otherwise.

Validate

Open the file in a store's previewer or run EPUBCheck. The errors that come up most are unescaped ampersands in text, a missing properties="nav", and mimetype in the wrong place.

Summary

Store mimetype first and uncompressed, convert each chapter to XHTML, write a nav document and a package file with every item in the manifest and the reading order in the spine, and zip it with jszip. The same script can feed a PDF build, covered in PDF from HTML with headless Chrome.

Related guides