I lost one Saturday trying to make existing SVG plugins work with Rslib. Most were Vite-only, some were outdated, and none fit Svelte or Vue properly. So I wrote two plugins instead:

@avatune/rsbuild-plugin-svg-to-svelte and @avatune/rsbuild-plugin-svg-to-vue

This post is basically why they exist.

The Setup

I'm building Avatune, an avatar component library that supports React, Vue, Svelte, and Vanilla JS. Same SVG assets, different framework outputs.

The architecture looks like this: raw SVG files for avatar parts (heads, eyes, hair, etc.) get transformed at build time into native framework components.

React has SVGR which handles this beautifully. Vue and Svelte? Not so much.

What I Tried First (And Why It Failed)

After 4-5 hours of this, I accepted reality: I'd need to write my own plugins.

Understanding Rsbuild's Plugin Architecture

If you haven't written an Rsbuild plugin before, here's the mental model. Rsbuild uses Rspack under the hood (think of it as a faster Webpack alternative written in Rust). You get access to a chain API that lets you modify module rules.

The key insight: you can add custom loaders that intercept specific file patterns.

/tipBy using query parameters like ?svelte, you can route the same .svg file through different loaders:

import HeadOval from './svg/head/oval.svg?svelte'//Svelte component
import HeadOval from './svg/head/oval.svg?vue'   //Vue component
import HeadOval from './svg/head/oval.svg?react' //React component (SVGR)

Same source file, different outputs. That's the goal.

The Plugin Structure

Both plugins follow the same basic shape:

export const pluginSvgToSvelte = (options = {}): RsbuildPlugin => ({
  name: 'avatune:svg-to-svelte',
  pre: ['rsbuild:svgr'],  // Run before SVGR claims the SVG
  setup(api) {
    api.modifyBundlerChain((chain, { CHAIN_ID }) => {
      const rule = chain.module.rule(CHAIN_ID.RULE.SVG).test(/\.svg$/)
    
      rule
        .oneOf('svg-svelte')
        .before(CHAIN_ID.ONE_OF?.SVG_URL)  // Priority matters
        .type('javascript/auto')
        .resourceQuery(/(^|\?)svelte($|&)/)
        .use('svelte-svg-loader')
        .loader(path.resolve(__dirname, './loader.mjs'))
        .options(options)
    })
  },
})

The pre: ['rsbuild:svgr'] is important. Without it, SVGR might grab the file first and you'll get React components when you wanted Svelte and Vue.

The Svelte Loader

Here's where it gets interesting. The loader does four things:

1. SVGO optimization - Minify the SVG, remove junk

2. Attribute replacement - Convert hardcoded colors to dynamic expressions

3. Svelte source generation - Build a proper .svelte component string

4. Compilation - Use svelte/compiler to turn it into JavaScript

Let me show you the attribute replacement part because that's where the magic happens.

My SVGs have hardcoded colors like fill="#FCBE93" for skin tones. I need these to become props. But I also need derived colors - a slightly darker shade for shadows, computed at runtime using the colord library.

The replacement map looks like this:

const replaceAttrValues = {
  '#FCBE93': '{color}',                              // Primary prop
  '#FFA882': '{colord(color).darken(0.05).toHex()}', // Darker shade
  '#272424': '{colord(color).darken(0.2).toHex()}',  // Shadow
  'mask0_89_489': '{uid + "-mask0"}',                // Unique ID
}

That last one is crucial. If you render two avatars on the same page and both have <mask id="mask0">, they clash. The uid prop fixes this.

The loader detects what's actually used in the SVG and only includes those props:

const usesColord = cleanSvg.includes('colord(')
const usesColor = cleanSvg.includes('{color}')
const usesUid = cleanSvg.includes('uid')
const props = []
if (usesColor) props.push("export let color = 'currentColor';")
if (usesUid) props.push("export let uid = '';")
const svelteSource = `<script>
  ${usesColord ? "import { colord } from 'colord';" : ''}
  ${props.join('\n  ')}
</script>
${finalSvg}
`

Then we compile it:

import { compile } from 'svelte/compiler'
const compiled = compile(svelteSource, {
  filename: resourcePath,
  generate: 'client',
  css: 'injected',
})
return compiled.js.code

And that's it. The output is a proper Svelte component that accepts color and uid props.

The Vue Loader: Where Things Get Annoying

Vue uses a different binding syntax. Svelte is happy with fill="{color}" , but Vue needs :fill="color". Same concept, different notation.

This means I can't just do string replacement and call it a day. I need to understand the context of each replacement.

// For regular attributes: fill="#FCBE93" → :fill="color"
const attrPattern = new RegExp(`(fill|stroke)="${escapedKey}"`, 'g')
result = result.replace(attrPattern, `:$1="${expression}"`)

And then there's URL references. SVGs use mask="url(#mask0)" syntax. To make that dynamic in Vue, you need a template literal:

// mask="url(#mask0)" → :mask="`url(#${uid + '-mask0'})`"
const urlPattern = new RegExp(
  `(mask|clip-path|fill|stroke)="url\\(#${escapedKey}\\)"`,
  'g'
)
result = result.replace(urlPattern, `:$1="\`url(#\${${expression}})\`"`)

Yeah, those escaping rules are brutal. I spent way too long debugging regex issues here.

After transformation, I use Vue's compiler:

import { compileTemplate } from '@vue/compiler-sfc'
const result = compileTemplate({
  source: processedSvg,
  filename: resourcePath,
  transformAssetUrls: false,
})
// Wrap in a component definition
const output = `
${result.code}
export default {
  name: 'SvgIcon',
  props: {
    color: { type: String, default: 'currentColor' },
    uid: { type: String, default: '' }
  },
  setup(props) {
    return { ...props, colord }
  },
  render
}
`

The SSR Problem

I shipped the plugins and everything worked great. Then someone tried to use the Svelte components with SvelteKit's SSR.

It broke.

Turns out, Svelte's SSR needs raw .svelte files, not compiled JavaScript. The server-side compiler wants to do its own thing.

So I added an escape hatch: the emitSvelteFiles option. After build, it writes actual .svelte files to disk:

pluginSvgToSvelte({
  // ... normal options
  emitSvelteFiles: {
    svgDir: './src/svg',
    outDir: './dist/svelte',
  },
})

This generates something like:

dist/svelte/
├── HeadOval.svelte
├── HairShort.svelte
├── EyesDots.svelte
└── index.js

Now SSR works. The compiled JS is for client-side bundling; the raw Svelte files are for SSR or anyone who wants to process them further.

How I Use This in Avatune

Here's the actual config from my assets package:

// rslib.config.ts
import { pluginRawSvg } from '@avatune/rsbuild-plugin-raw-svg'
import { pluginSvgToSvelte } from '@avatune/rsbuild-plugin-svg-to-svelte'
import { pluginSvgToVue } from '@avatune/rsbuild-plugin-svg-to-vue'
import { pluginSvgr } from '@rsbuild/plugin-svgr'
export default defineConfig({
  plugins: [
    pluginSvgr({
      svgrOptions: {
        svgoConfig,
        replaceAttrValues: getReplaceAttrValues('props.color', 'props.uid'),
      },
    }),
    pluginSvgToVue({
      svgo: true,
      svgoConfig,
      imports: "import { colord } from 'colord';",
      replaceAttrValues: getReplaceAttrValues('color'),
    }),
    pluginSvgToSvelte({
      svgo: true,
      svgoConfig,
      imports: "import { colord } from 'colord';",
      replaceAttrValues: getReplaceAttrValues('color'),
      emitSvelteFiles: {
        svgDir: './src/svg',
        outDir: './dist/svelte',
      },
    }),
    pluginRawSvg({
      svgo: true,
      svgoConfig,
      imports: "import { colord } from 'colord';",
      replaceAttrValues: getReplaceAttrValues('color'),
    }),
  ],
})

Four plugins, one config. Same SVGs become React components, Vue components, Svelte components, and raw template strings (for Vanilla JS).

Was It Worth It?

Honestly? Yeah.

The maintenance burden is real - I now own two plugins that I need to keep updated with Svelte and Vue compiler changes. But the alternative was worse: either drop framework support, or accept the performance hit of runtime transformation.

For a library that's all about rendering customizable avatars quickly, build-time wins.

If You're Facing Something Similar

Here's my advice:

1. Spend real time evaluating existing solutions first**.** I could've saved effort if something worked. It didn't.

2. Understand your bundler's extension points. Rsbuild's modifyBundlerChain is powerful once you grok it.

3. Start with the simplest case. Get one SVG transforming correctly before handling edge cases.

4. Test with actual files from your project. Synthetic test cases miss real-world complexity - masks, gradients, nested groups.

5. Plan for SSR if relevant. I didn't, and it bit me.

The plugins are published as @avatune/rsbuild-plugin-svg-to-svelte and @avatune/rsbuild-plugin-svg-to-vue if you want to try them. They're opinionated toward my use case (color props, uid handling, colord integration) but might save you some time.

Or fork them and strip out what you don't need. That's the beauty of writing your own - you understand exactly what it does.

Building Avatune at avatune.dev. Questions? Find me on Linkedin or open an issue on GitHub.