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
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)
vite-plugin-svelte-svg- Only works with Vite. I use Rslib (Rspack-based). Dead end.vue-svg-loader- Webpack-only, hasn't been updated in years. The maintainer moved on.Wrapping SVGs in innerHTML- Works but kills reactivity. I needed actual props like<HeadOval color="#FCBE93" />. Not gonna work.Runtime parsing- Could parse SVGs at runtime using DOMParser or svelte's {@html}. But that adds bundle size and latency. For an avatar library rendering potentially dozens of components, build-time transformation is the only sane choice.
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.