Why generate documents in the browser?
Traditional document generation requires a server: your web application collects data, sends it to a backend, the backend generates a file, and sends it back for download. This works, but it adds complexity, latency, server cost, and — critically — it means user data must travel over the network.
Client-side generation eliminates all of that. The document is created right in the user's browser tab using JavaScript. The file never touches a server. This matters for:
- Privacy and data sovereignty — sensitive data like medical records, legal documents, financial reports, or personal information stays on the user's device
- Offline-capable applications — PWAs and offline tools can generate documents without any network connection
- Reduced infrastructure — no server-side document generation pipeline to build, scale, or maintain
- Government and compliance — many public sector applications must minimize data transmission and generate documents in the open ISO standard format (ODF / ISO/IEC 26300)
What you'll build
By the end of this guide, you'll have a web page with a button that generates a formatted .odt document and downloads it — entirely in the browser. The document will include headings, formatted text, a table, and a list. It opens in LibreOffice, Google Docs, Microsoft Word, and any ODF-compliant application.
Prerequisites
- A JavaScript project with a bundler (Vite, webpack, esbuild, Rollup, Parcel — anything that supports
npm install) - Basic familiarity with ES modules (
import/export)
If you don't have a project yet, the fastest way to start is with Vite:
# Create a new Vite project npm create vite@latest my-odt-app -- --template vanilla cd my-odt-app npm install
Step 1 — Install odf-kit
odf-kit has zero transitive runtime dependencies. It adds minimal weight to your bundle — no XML parsers, no heavy frameworks.
Step 2 — Generate a document and trigger a download
Here's the complete pattern for creating an .odt file in the browser and downloading it:
import { OdtDocument } from "odf-kit"; async function generateDocument() { const doc = new OdtDocument(); doc.addHeading("Quarterly Report", 1); doc.addParagraph("Generated entirely in the browser."); doc.addTable([ ["Region", "Revenue", "Growth"], ["North", "$2.1M", "+12%"], ["South", "$1.8M", "+8%"], ], { border: "0.5pt solid #000" }); const bytes = await doc.save(); // Create a download link const blob = new Blob([bytes], { type: "application/vnd.oasis.opendocument.text", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "report.odt"; a.click(); URL.revokeObjectURL(url); } // Wire it to a button document.querySelector("#generate-btn") .addEventListener("click", generateDocument);
That's it. When the user clicks the button, a valid .odt file is generated in memory, converted to a Blob, and downloaded. No network requests. No server. The data exists only in the browser tab.
Step 3 — Fill a template from a file input
If you have an existing .odt template created in LibreOffice, you can let the user upload it and fill it with data — entirely client-side:
import { fillTemplate } from "odf-kit"; const input = document.querySelector("#template-file"); input.addEventListener("change", async (e) => { const file = e.target.files[0]; const buffer = new Uint8Array( await file.arrayBuffer() ); const result = fillTemplate(buffer, { customer: "Acme Corp", date: new Date().toLocaleDateString(), items: [ { product: "Widget", qty: 5, price: "$125" }, { product: "Gadget", qty: 3, price: "$120" }, ], }); // Download the filled document const blob = new Blob([result], { type: "application/vnd.oasis.opendocument.text", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "invoice.odt"; a.click(); URL.revokeObjectURL(url); });
The template file is read locally with the File API. It never leaves the browser. The fillTemplate() function replaces all {placeholders} with your data, including loops ({#items}...{/items}) and conditionals. The filled document downloads immediately.
Step 4 — Add images from fetch or file input
In the browser, you load images with fetch() instead of fs.readFile():
const response = await fetch("logo.png"); const logoBytes = new Uint8Array( await response.arrayBuffer() ); doc.addImage(logoBytes, "image/png", { width: "5cm", height: "2cm", });
You can also get image bytes from a <canvas> element, a user-uploaded file, or any other source that produces a Uint8Array or ArrayBuffer.
How it works under the hood
An .odt file is a ZIP archive containing XML files and embedded resources. odf-kit builds the XML in memory, compresses it with fflate (a fast, pure-JavaScript ZIP library), and returns the result as a Uint8Array. There are no Node.js-specific APIs anywhere in the library — the TypeScript build enforces this at compile time. The same code runs in Node.js, browsers, Deno, Bun, and Cloudflare Workers.
The Blob + URL.createObjectURL() pattern is the standard way to trigger file downloads in the browser without a server. The browser creates a temporary URL pointing to the in-memory data, the <a> element triggers the download dialog, and revokeObjectURL() frees the memory.
Browser compatibility
odf-kit works in all modern browsers: Chrome, Firefox, Safari, and Edge. It uses only standard web APIs — TextEncoder, Uint8Array, Blob, URL.createObjectURL() — that are available in all browsers shipped since 2018. No polyfills needed.
Complete example — "Export as .odt" button
Here's a realistic example: a web application that lets users generate a formatted report and download it as an .odt file:
import { OdtDocument } from "odf-kit"; async function exportReport(data) { const doc = new OdtDocument(); doc.setMetadata({ title: data.title }); doc.setFooter("Page ###"); doc.addHeading(data.title, 1); doc.addParagraph(`Generated on ${new Date().toLocaleDateString()}`); doc.addHeading("Summary", 2); doc.addParagraph(data.summary); doc.addHeading("Data", 2); doc.addTable( [data.columns, ...data.rows], { border: "0.5pt solid #000" } ); doc.addHeading("Notes", 2); doc.addList(data.notes); const bytes = await doc.save(); const blob = new Blob([bytes], { type: "application/vnd.oasis.opendocument.text", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${data.title}.odt`; a.click(); URL.revokeObjectURL(url); } // Use it with your application data exportReport({ title: "Q4 Sales Report", summary: "Revenue exceeded targets in all regions.", columns: ["Region", "Revenue", "Growth"], rows: [ ["North", "$2.1M", "+12%"], ["South", "$1.8M", "+8%"], ["East", "$1.5M", "+15%"], ], notes: [ "All figures are unaudited", "Growth is year-over-year", ], });
When to use browser generation vs. server generation
Client-side generation is ideal when you want to keep data private, reduce server load, or build offline-capable tools. Server-side generation (Node.js) is better when you need to batch-generate hundreds of documents, integrate with databases, or run in a CI/CD pipeline. odf-kit uses the same API in both environments — the only difference is how you read input files and write output files.
What about .odt instead of .docx?
.odt is the OpenDocument Format — an ISO international standard (ISO/IEC 26300) that's vendor-independent and open. It's the default format for LibreOffice, and it's required by many governments including the European Union, Germany, France, Italy, the Netherlands, the United Kingdom, Brazil, and India. If your users work with LibreOffice, or your application serves public sector organizations, .odt is the right choice. The files also open in Google Docs and Microsoft Word.
Also works server-side
The same API works in Node.js 18+, Deno, Bun, and Cloudflare Workers. See the Generate .odt Files in Node.js guide for server-side examples, or the Fill .odt Templates with JavaScript guide for the template engine.