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 (3)

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
 
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

I'd appreciate any advice from experienced developers❤️