Optimizing SVG with SVGO

Design tools export metadata, hidden layers, redundant groups and six-decimal coordinates. SVGO 22,703 (github.com/svg/svgo (https://github.com/svg/svgo 22,703 ), MIT, 4.1.0 of August 2026) parses a file into a tree and runs plugins over it: preset-default strips comments and metadata, rounds numbers, merges paths, bakes transforms into path data and shortens IDs. Version 4 dropped removeViewBox and removeTitle from the preset, the two that most often broke scaling and accessibility.

SVGO on the BookNest logo and sprite, then with a sprite-safe configurationShell
mkdir -p min
npx -y svgo@4.1.0 --no-color booknest-logo.svg booknest-icons.svg \
  -o min/booknest-logo.svg min/booknest-icons.svg
grep -o '<symbol' min/booknest-icons.svg | wc -l
npx -y svgo@4.1.0 --no-color --config sprite.config.mjs booknest-icons.svg \
  -o min/booknest-icons.svg
grep -o 'symbol id="[a-z-]*"' min/booknest-icons.svg | wc -l
Output
booknest-icons.svg:
Done in 25 ms!
1.264 KiB - 93.2% = 0.086 KiB
booknest-logo.svg:
Done in 12 ms!
1.123 KiB - 24.3% = 0.851 KiB
0
booknest-icons.svg:
Done in 32 ms!
1.264 KiB - 6.4% = 1.183 KiB
9

The logo lost a quarter, its spines' rotate() baked into coordinates, but id="mark" became id="a". The sprite "lost" 93% because every <symbol> was deleted: nothing in the file uses them. This configuration keeps them:

sprite.config.mjsShell
export default { plugins: [{ name: 'preset-default', params: { overrides: {
  removeHiddenElems: false, removeUselessDefs: false,  // both delete <symbol>s the file never uses
  cleanupIds: false,                                    // keep id="i-cart" for <use href="#i-cart">
} } }] };

Check optimized files visually and diff their IDs before shipping; gzip or Brotli then compress the text further.

A mini SVGO pass: strip metadata, round numbers and compare bytesHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; max-width: 600px; }
  .cols { display: flex; gap: 10px; }
  .col { flex: 1; background: #fff; border: 1px solid #d8d4c8; border-radius: 8px; padding: 8px; min-width: 0; }
  .col svg { width: 100%; height: 110px; display: block; }
  pre { font-size: 10px; background: #f3f0e8; padding: 6px; height: 130px; overflow: auto; white-space: pre-wrap; word-break: break-all; margin: 6px 0 0; }
  h3 { margin: 0 0 4px; font-size: 14px; }
  .bar { height: 14px; background: #3d5a80; border-radius: 3px; margin-top: 4px; }
</style>
<div class="cols">
  <div class="col"><h3>Exported by an editor</h3><div id="before"></div><div id="bBytes"></div><div class="bar" id="bBar"></div><pre id="bSrc"></pre></div>
  <div class="col"><h3>After optimizing</h3><div id="after"></div><div id="aBytes"></div><div class="bar" id="aBar" style="background:#81b29a"></div><pre id="aSrc"></pre></div>
</div>
<script>
  // A BookNest logo as a design tool might export it
  const exported = `<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Some Vector Editor 28.1 -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:editor="http://example.com/editor" viewBox="0 0 120.000000 80.000000">
  <metadata><editor:doc author="designer" created="2026-08-01"/></metadata>
  <title>BookNest logo</title>
  <g id="Layer_1">
    <g id="Group_7">
      <rect id="spine_1" x="20.000000" y="15.333333" width="14.666667" height="50.000000" fill="#3D5A80"/>
      <rect id="spine_2" x="38.123456" y="10.987654" width="14.666667" height="54.345679" fill="#E07A5F"/>
      <rect id="spine_3" x="56.246912" y="20.111111" width="14.666667" height="45.222222" fill="#81B29A"/>
    </g>
    <g id="Hidden_guides" display="none"><rect x="0" y="0" width="120" height="80" fill="none" stroke="cyan"/></g>
    <path id="nest" d="M 12.500000 66.000000 Q 60.000000 84.500000 107.500000 66.000000" fill="none" stroke="#6B4F3A" stroke-width="4.000000"/>
  </g>
</svg>`;

  // A few of preset-default's plugins, imitated with regular expressions for illustration
  // (the real SVGO parses the file into a tree first).
  function optimize(src) {
    return src
      .replace(/<\?xml[^>]*\?>\s*/g, '')                       // removeXMLProcInst
      .replace(/<!--[\s\S]*?-->\s*/g, '')                       // removeComments
      .replace(/<metadata>[\s\S]*?<\/metadata>\s*/g, '')        // removeMetadata
      .replace(/\s+xmlns:editor="[^"]*"/g, '')                  // removeEditorsNSData
      .replace(/<g[^>]*display="none"[\s\S]*?<\/g>\s*/g, '')    // removeHiddenElems
      .replace(/\s+id="[^"]*"/g, '')                            // cleanupIds (none are referenced)
      .replace(/\d+\.\d+/g, n => String(+(+n).toFixed(1)))      // cleanupNumericValues
      .replace(/#[0-9A-F]{6}/gi, c => c.toLowerCase())          // convertColors
      .replace(/<\/?g>/g, '')                                   // collapseGroups (no attributes left)
      .replace(/>\s+</g, '><')                                  // minify whitespace
      .trim();
  }

  const minified = optimize(exported);
  const show = (id, src) => {
    document.getElementById(id).innerHTML = src.replace(/<\?xml[^>]*\?>/, '');
    document.getElementById(id[0] + 'Src').textContent = src;
  };
  show('before', exported);
  show('after', minified);
  const b = new Blob([exported]).size, a = new Blob([minified]).size;
  document.getElementById('bBytes').textContent = `${b} bytes`;
  document.getElementById('aBytes').textContent = `${a} bytes (−${Math.round(100 - a / b * 100)}%), viewBox and title kept`;
  document.getElementById('bBar').style.width = '100%';
  document.getElementById('aBar').style.width = (a / b * 100) + '%';
</script>