mrkeyoor.com_
Thu 06 Aug 07:40 UTC
npmUtilsupdated 06 Aug 2026

xmlbuilder2

xmlbuilder2 builds and serializes XML documents in Node. You start with create(), chain ele() for elements, att() for attributes, txt() for text, and up() to walk back to the parent, then call end() to get a string. It also goes the other way: hand create() an XML string, a JS object, JSON, or YAML and it parses that into the same tree, which you can edit and re-serialize into any of those formats. Underneath it is a real DOM implementation, so what you are manipulating is a spec-compliant document rather than a plain object graph.

Verdict

The most correct XML builder in the Node ecosystem, and worth it when the output has to satisfy a schema or a namespace-sensitive consumer. It is a Node-side tool: keep it out of browser bundles and pick a dedicated parser if reading XML is your main job.

API stability5/5create, ele, att, txt, up, and end have not changed since 1.0; the only breaking change in 4.0.0 was raising the Node floor to 20, and the 3.0.0 change before it was about entity decoding rather than call signatures.
Docs4/5The GitHub Pages site has a real structure: quickstart, per-function reference, serialization and parsing option tables, namespace and callback-API guides, with runnable examples throughout. It thins out on error handling and on which DOM methods are reachable through .node.
Maintenance3/5One maintainer, a two-and-a-half year gap between 3.1.1 in May 2023 and 4.0.0 in October 2025, then a burst of fixes into early 2026 and a last push in May 2026; 18 open issues and 12 open PRs. Alive but slow, and there is no second committer.
Ecosystem4/521.0M weekly downloads, mostly transitive through AWS and Azure SDK tooling, sitemap generators, and Office document libraries. It is the standard builder, but the surrounding XML ecosystem in Node splits across fast-xml-parser, xml2js, and sax rather than centring on it.

Use it if

  • You are generating XML that a picky consumer will validate: sitemaps, RSS and Atom feeds, SOAP envelopes, SEPA or UBL invoices, Office Open XML parts
  • You need correct namespace handling with prefixes and declarations, which is where hand-rolled template strings and simpler builders fall apart
  • You want to round-trip: parse a supplier's XML, patch two nodes, and write it back out with the declaration, doctype, and CDATA sections intact
  • You are writing a very large XML file and want the callback API (createCB) to stream it out node by node instead of holding the whole tree in memory
  • You are migrating from the original xmlbuilder and want the same chaining style with the parsing and conversion features it never had
Skip it if

Setup reality

npm install xmlbuilder2 pulls three @oozcitak packages plus js-yaml, and the install itself is uneventful. The friction is elsewhere. The package is CommonJS with no exports map, so ESM projects import it through Node's interop and TypeScript under NodeNext may need esModuleInterop; named imports like import { create } from 'xmlbuilder2' do work in Node ESM but bundlers targeting the browser will pull the whole DOM implementation in. Node 20 is the hard floor since 4.0.0. The real learning curve is up(): every ele() returns the new child, so a chain that forgets to walk back attaches the next element in the wrong place and you get valid XML with the wrong shape, which no error will warn you about.

Patterns

Build a document by chaining elementsbuild-with-chaining

const { create } = require('xmlbuilder2')

const root = create({ version: '1.0', encoding: 'UTF-8' })
  .ele('root', { att: 'val' })
    .ele('foo')
      .ele('bar').txt('foobar').up()
    .up()
    .ele('baz').up()
  .up()

console.log(root.end({ prettyPrint: true }))
// <?xml version="1.0" encoding="UTF-8"?>
// <root att="val">
//   <foo>
//     <bar>foobar</bar>
//   </foo>
//   <baz/>
// </root>

ele() returns the child, not the parent, so every nesting level needs a matching up(). end() always serializes the whole document no matter which node you call it on.

Turn a plain JS object into XMLbuild-from-object

const { create } = require('xmlbuilder2')

const doc = create({
  order: {
    '@id': 'A-100',
    customer: { '#': 'Acme Ltd' },
    lines: [{ line: 'widget' }, { line: 'gasket' }],
  },
})

console.log(doc.end({ prettyPrint: true }))

'@' prefixes an attribute and '#' is the text content of the current element. Arrays repeat the wrapping element, and null values are dropped entirely unless you pass { keepNullNodes: true } as the first argument to create.

Parse existing XML, patch it, write it backparse-and-edit

const { create } = require('xmlbuilder2')

const doc = create('<root att="val"><foo><bar>foobar</bar></foo></root>')

doc.root().ele('baz').txt('added').up()
doc.root().att('att', 'changed')

console.log(doc.end({ prettyPrint: true }))

create() sniffs its input: an XML string, a JSON string, a YAML string, or a JS object all land in the same tree. doc.root() is the document element; calling ele() on doc itself would try to add a second root.

Convert between XML, objects, and JSONxml-to-object

const { convert } = require('xmlbuilder2')

const xml = '<r a="1"><i>one</i><i>two</i></r>'

convert(xml, { format: 'object' })
// { r: { '@a': '1', i: [ 'one', 'two' ] } }

convert(xml, { format: 'json', prettyPrint: true })
convert({ r: { '@a': '1' } }, { format: 'xml' })

Repeated sibling elements collapse into an array only when there is more than one, so a single <i> gives you a string and two give you an array. Pass { verbose: true } to always get arrays and stop writing Array.isArray checks.

Create namespaced elements and attributesnamespaces

const { create } = require('xmlbuilder2')

const doc = create()
  .ele('http://schemas.xmlsoap.org/soap/envelope/', 'soap:Envelope')
    .ele('soap:Body')
      .ele('http://example.com/stock', 'm:GetPrice')
        .ele('m:StockName').txt('ACME').up()
      .up()
    .up()
  .up()

console.log(doc.end({ prettyPrint: true }))

Pass the namespace URI as the first argument and the qualified name second; the xmlns declaration is emitted for you. Children inside the same prefix do not need the URI repeated.

Control the shape of the output stringserialization-options

doc.end({ prettyPrint: true, indent: '    ' })
doc.end({ headless: true })              // no <?xml ...?> declaration
doc.end({ format: 'object' })
doc.end({ format: 'json', prettyPrint: true })
doc.end({ wellFormed: true })            // throw instead of emitting invalid XML

headless is what you want when the fragment is going inside another document. wellFormed turns silent corruption into an exception and is worth enabling in tests even if it is off in production.

Find, filter, and delete nodestraverse-and-remove

const doc = create('<r><a id="1">x</a><a id="2">y</a><b/></r>')
const root = doc.root()

const target = root.find(
  (n) => n.node.nodeName === 'a' && n.node.getAttribute('id') === '2',
)
target.txt(' updated')

root.filter((n) => n.node.nodeName === 'b').forEach((n) => n.remove())

root.each((child) => console.log(child.node.nodeName))

There is no XPath and no selector string: predicates over .node are the query language. remove() returns the parent, so chaining after it continues from one level up.

Write a huge document without building the treestreaming-output

const { createCB } = require('xmlbuilder2')
const fs = require('fs')

const out = fs.createWriteStream('rows.xml')
const xml = createCB({
  prettyPrint: true,
  data: (chunk) => out.write(chunk),
  end: () => out.end(),
})

xml.dec({ version: '1.0' }).ele('rows')
for (const row of millionsOfRows) {
  xml.ele('row').att('id', row.id).txt(row.name).up()
}
xml.up().end()

The callback API is write-once and forward-only: no find, no going back to a node you already closed. Nothing is buffered, so back-pressure on the write stream is yours to handle.

Build a detached fragment and splice it infragments-and-import

const { create, fragment } = require('xmlbuilder2')

const rows = fragment()
  .ele('row').txt('a').up()
  .ele('row').txt('b').up()

const doc = create().ele('table')
doc.import(rows)

console.log(doc.end({ prettyPrint: true }))

A fragment has no document element, so it can hold several top-level nodes. import() copies the nodes in rather than moving them, which means reusing the same fragment twice is safe.

Emit comments, CDATA, processing instructions, and a doctypecdata-comments-doctype

const doc = create()
  .dtd({ name: 'html', pubID: '-//W3C//DTD XHTML 1.0 Strict//EN', sysID: 'xhtml1-strict.dtd' })
  .ele('html')
    .com('generated nightly')
    .dat('<raw markup & entities>')
    .ins('xml-stylesheet', 'type="text/xsl" href="s.xsl"')
  .up()

console.log(doc.end({ prettyPrint: true }))

dat() writes a CDATA section, so its contents are not escaped and a literal ']]>' inside will break the document. dtd() attaches to the document wherever you call it, so the doctype still lands above the root even if you add it last.

Know what gets silently droppedescaping-gotchas

create().ele('r').att('a', undefined).end()
// <r/>  attribute vanishes, no error

create({ r: { n: 42, flag: true, missing: null } }).end()
// <r><n>42</n><flag>true</flag></r>

try {
  create().ele('1bad')
} catch (e) {
  // InvalidCharacterError: Invalid XML name: 1bad
}

Undefined attributes and null object values disappear without a warning, which quietly produces XML that fails schema validation downstream. Element names starting with a digit throw InvalidCharacterError, so sanitise names built from user data.

Alternatives

PackageRegistryPick it when
fast-xml-parsernpmYou are mostly parsing XML into plain JS objects and want speed with a small dependency footprint.
xml2jsnpmYou want the long-established callback and promise API for XML to object conversion in existing Node codebases.
xmlbuildernpmYou only build XML, never parse it, and want the older lighter package with the same chaining style.
jstoxmlnpmYou just need to turn a plain object into an XML string and want a tiny dependency-free helper.