DEV Community

Cover image for From Raw SVG to Production JSX: DOMParser, TypeScript, and Manifest V3
Vladimir PPC
Vladimir PPC

Posted on

From Raw SVG to Production JSX: DOMParser, TypeScript, and Manifest V3

Forty-two minutes. That’s the context-switch tax I lost last Tuesday wrestling a single navigation icon. I dropped a raw Figma export into VS Code, immediately hit the JSX friction wall: class instead of className, hyphenated attributes triggering ESLint, and legacy xlink:href tags throwing the TSX compiler into a syntax error. I manually renamed props, wrapped it in a functional component, and wrote type definitions. By the time I finished, my flow was dead.

This isn't an edge case; it's a daily grind. I tried cloud-based converters. They're slow, stuffed with third-party telemetry, and I'm absolutely not feeding unreleased design assets to a remote subdomain. I even spun up a Node script with SVGR's CLI. It handled clean exports fine, then immediately choked on a complex Illustrator file. Mixed XML namespaces and nested layer groups broke the AST parser. I needed instant, deterministic, offline processing.

The Core Engine: DOMParser & Attribute Mapping

I architected this as a Chrome Extension running entirely under Manifest V3. The transformation pipeline relies on the native DOMParser API, executing synchronously in an isolated background context. I pipe the raw SVG string, traverse the DOM tree, and apply a strict dictionary for camelCase conversions.

Here’s the conceptual mapping logic:

const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "image/svg+xml");

const ATTR_MAP = {
  class: 'className',
  'stroke-width': 'strokeWidth',
  'fill-rule': 'fillRule',
  'stroke-linecap': 'strokeLinecap'
};

function mapAttributes(node) {
  Array.from(node.attributes).forEach(attr => {
    const targetName = ATTR_MAP[attr.name] || attr.name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
    if (targetName !== attr.name) {
      node.removeAttribute(attr.name);
      node.setAttribute(targetName, attr.value);
    }
  });
  Array.from(node.children).forEach(mapAttributes);
}
Enter fullscreen mode Exit fullscreen mode

The Namespace Crash & JSX Resolution

DOMParser handles raw XML fine, but browsers play fast and loose with XML namespaces. React’s JSX compiler does not. My initial build crashed the minute I dropped in an animated chart SVG. It packed xmlns:xlink declarations and inline <use> tags referencing xlink:href. Modern browsers parse this silently, but when serialized to JSX, the unparsed namespace prefixes and deprecated attributes throw immediate syntax errors during the tsdx/esbuild transpilation step.

I had to implement a recursive cleanup pass before serialization:

  1. Namespace Stripping: Explicitly walk the tree, detect xmlns:xlink, and drop the attribute declaration from the root.
  2. Legacy Migration: Query all <use> and <a> tags. Copy the xlink:href value to a standard href attribute, then safely delete xlink:href.
  3. Security Hardening: Explicitly block and sanitize <script>, <foreignObject>, and inline event handlers (onload, onclick) during traversal. This forces the pipeline to strictly output declarative, safe markup.

Once the attribute resolver locked in, the transformer became fully synchronous.

Security: Manifest V3 Sandbox & Zero Network Requests

Because this runs under Manifest V3, the entire pipeline executes in a strictly sandboxed background service worker. There are zero fetch calls, zero CDN dependencies, and absolutely no external server routing. Every transformation happens clientside in memory. If your design files contain proprietary dashboard UI logic, they never leave the local machine. The isolated execution model guarantees deterministic output without telemetry or external payload validation.

Production Workflow & TypeScript Integration

I wired the workflow to Alt+S. Hit the shortcut, paste the markup, or drag a .svg directly into the popup. You get clean JSX in under 200ms. Handling edge cases manually got tedious, so I baked in the production features I actually use:

  • Batch Processing: Drop dozens of icons, output a single bundled .ts file or a structured .zip.
  • React Hydration: Auto-wraps exports in React.memo, injects forwardRef so tooltip/popover libraries don't panic, and swaps hardcoded hex values to currentColor for seamless CSS variable overrides.
  • Strict TypeScript: Bakes in React.SVGProps<SVGSVGElement> definitions out of the box. The compiler stays quiet, and IntelliSense works immediately.

Shipping it brought the usual infra headaches. I wired up chrome.i18n from day one, shipping RU, EN, DE, ES, FR, PT, ZH, and JA. It felt like overhead until download velocity spiked from Germany and Japan. For licensing, I initially tried Lemon Squeezy but hit domain verification walls. Migrated to Gumroad, skipped the compliance queue, and shipped a lifetime access checkout. It killed 2 AM support tickets and simplified the revenue pipeline.

Conclusion

Developers don't hate configuration; we hate losing flow. Tools that demand logins, network handshakes, and confirmation modals break context. Local execution isn't a technical flex—it's the baseline for serious engineering workflows. I built this to stop bleeding time on Figma exports while deadlines loomed. Turns out, the friction was universal.

If you want to cut the manual cleanup tax and process SVGs entirely offline, you can grab the extension here:

https://chromewebstore.google.com/detail/pjadcdedmjliokpjfcholckcnkaheobc?utm_source=devto&utm_medium=article&utm_campaign=svg_to_react_launch

Drop a comment if you're running into specific namespace edge cases or have ideas for the next AST traversal pass.

Top comments (10)

Collapse
 
wrencalloway profile image
Wren Calloway

@vcoder_studio Nice, the early bailout is the big one — that alone kills the most common class of silent-drop bugs. Glad it helped.

One thing to double-check on the xml:space fix: React's SVG allowlist maps xml:space to xmlSpace, but it also expects xmlLang, xmlBase, and the xlink family (xlinkHref, xlinkTitle, etc.) with that specific casing. A blanket colon-to-camelCase rule gets xml:space right by luck, but xlink:href would come out xlinkHref too — which happens to match, so you're probably fine. The one that bites is xml:base if you ever hit it, since the naive transform gives you xmlBase and React does want xmlBase — so also fine, actually. The real landmines are attributes where the collapse doesn't match React's expected key, which is exactly why the allowlist wins: you never have to reason about whether the transform coincidentally lands on the right string.

No rush on refactoring, but when a real Illustrator export throws something weird at you, that's your signal — the catch-all will fail quietly and the allowlist would've thrown loud. Loud is what you want here.

Collapse
 
wrencalloway profile image
Wren Calloway

Your mapAttributes regex handles kebab-to-camel, but it'll happily mangle the attributes that are supposed to stay hyphenated. data-* and aria-* pass straight through to the DOM as-is in JSX — React explicitly does not camelCase those. Run data-testid through your attr.name.replace(/-([a-z])/g, ...) and you emit dataTestid, which React will warn about (unknown prop) and drop silently. Same trap for any custom namespaced attr on a <use>. You want an early bailout: if attr.name starts with data- or aria-, skip the transform entirely.

The subtler one is the two SVG attributes that genuinely collide with the pattern: things like xml:lang or xml:space and vendor prefixes. Your xlink:href migration handles the one everyone hits, but a strict allowlist for what gets camelCased (rather than a catch-all regex fallback) is going to be more deterministic than "transform everything I don't recognize" — which is the opposite of what SVGR learned the hard way with real Illustrator exports.

Collapse
 
vcoder_studio profile image
Vladimir PPC

You're totally right. Your comment actually made me look at the regex again, and I pushed an update this weekend!

I added an early bailout, so the parser now explicitly ignores data-* and aria-* tags and passes them through as-is. I also fixed the colon handling, so things like xml:space correctly camelCase to xmlSpace now.

Your point about using a strict allowlist instead of a catch-all regex (like SVGR learned the hard way) is pure gold. I'm keeping the current logic for now, but if more edge cases pop up, I'll definitely refactor to an allowlist approach. Really good catch!

Collapse
 
xm_dev_2026 profile image
Xiao Man

The namespace crash with xmlns:xlink is one of those SVG→JSX traps that doesn't show up until you hit a complex file from a real design tool. Most converters handle the happy path fine and silently break on animated charts or references. Your three-step cleanup (strip declaration → migrate xlink:href → security sanitize) is the right pipeline order.

One thing I've found with MV3 background service workers: if your DOMParser transformation takes longer than ~30 seconds on batch processing, the service worker can get terminated mid-job. Chrome's MV3 model doesn't guarantee long-running workers. Have you hit this with large batch drops? A possible fix is chunking the batch and checkpointing state via chrome.storage.local between chunks.

Also — shipping 8 languages from day one is bold for a utility extension. Gumroad over Lemon Squeezy is the right call for a lifetime deal model. The domain verification step on LSP is a real friction point for solo builders.

Collapse
 
vcoder_studio profile image
Vladimir PPC

Thanks for reading! Yeah, the MV3 lifecycle is a real pain. Honestly, the DOMParser is so fast (under 200ms per icon) that even with 50+ files it finishes in a few seconds, so we haven't hit the 30s timeout yet.

But you're right — if someone drops 500 complex SVGs, it might crash. Saving chunks to local storage is a great idea, definitely adding that to the todo list for v2.

And glad to hear I'm not the only one who struggled with Lemon Squeezy's domain verification. Gumroad saved me so much headache.

Collapse
 
xm_dev_2026 profile image
Xiao Man

Good to know the 200ms per icon holds in practice — real-world SVGs from design tools are the true stress test. The chunking approach for v2 is solid. One thing worth considering: instead of local storage (5MB limit), you might want IndexedDB for the intermediate parsed results. That way you can store full Document objects serialized, and resume from where you left off if the service worker gets killed mid-batch. The "500 complex SVGs" scenario is unlikely but the pattern scales to any long-running task in MV3.

Thread Thread
 
vcoder_studio profile image
Vladimir PPC

That's a brilliant point about the 5MB limit. I completely overlooked that a massive batch of raw serialized SVGs could blow past chrome.storage.local.

Since the conversion currently runs directly in the popup context, I ended up writing a quick async chunker instead. It processes 25 files at a time and yields the main thread with a tiny setTimeout. This keeps the UI responsive (live progress) and stops the browser from freezing or killing the tab, even with a 500+ file drop.

Your IndexedDB idea is the perfect architecture though, especially if I ever move this heavy lifting to a background service worker. Thanks for the sanity check!

Collapse
 
frank_signorini profile image
Frank

How do you handle SVG parsing errors with DOMParser in a TypeScript setup? I've had issues with it in the past and would love to swap ideas on this.

Collapse
 
vcoder_studio profile image
Vladimir PPC

Actually, DOMParser is a bit weird because it doesn't throw errors. It just returns a document with a <parsererror> node inside. So in TS, I usually do something like this to validate the output:


typescript
const doc = parser.parseFromString(svgString, "image/svg+xml");
const errorNode = doc.querySelector("parsererror");

if (errorNode) {
  throw new Error(errorNode.textContent || "Parsing failed");
}
This keeps TypeScript happy since querySelector just returns Element | null, and we can easily grab the text content for debugging.
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vcoder_studio profile image
Vladimir PPC

I'd appreciate any advice from experienced developers❤️