Build a Vite plugin from scratch
By Flavio Copes
Build a Vite plugin from scratch that transforms custom note files, exposes a virtual module, supports development and production, and adds tests.
Vite plugins let us teach Vite how to handle something it does not understand by default.
A plugin can:
- transform a custom file format
- provide modules that do not exist on disk
- rewrite imports
- add development-server behavior
- generate build files
In this tutorial we’ll build vite-plugin-note.
It will turn this file:
---
title: Hello Vite
---
This content came from a .note file.
into a JavaScript module:
import note from './welcome.note'
console.log(note.title)
console.log(note.content)
We’ll also expose a virtual module that lists every imported note.
Create a Vite project
npm create vite@latest note-demo -- --template vanilla
cd note-demo
npm install
Create vite-plugin-note.js.
A Vite plugin is usually a factory function that returns an object:
export function notePlugin() {
return {
name: 'vite-plugin-note',
}
}
The name appears in warnings and debugging tools. Keep it unique and descriptive.
Add it to vite.config.js:
import { defineConfig } from 'vite'
import { notePlugin } from './vite-plugin-note.js'
export default defineConfig({
plugins: [notePlugin()],
})
Transform .note files
The transform hook receives source code and its module ID.
Add it to the plugin:
export function notePlugin() {
return {
name: 'vite-plugin-note',
transform(source, id) {
if (!id.endsWith('.note')) {
return
}
const note = parseNote(source, id)
return {
code: `export default ${JSON.stringify(note)}`,
map: null,
}
},
}
}
Hooks should return undefined when they do not handle a module. This lets the rest of the plugin pipeline continue normally.
Now add the parser above the factory:
function parseNote(source, id) {
const match = source.match(
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
)
if (!match) {
throw new Error(
`[vite-plugin-note] ${id} needs a frontmatter block`
)
}
const metadata = Object.fromEntries(
match[1]
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const separator = line.indexOf(':')
if (separator === -1) {
throw new Error(
`[vite-plugin-note] Invalid metadata in ${id}`
)
}
return [
line.slice(0, separator).trim(),
line.slice(separator + 1).trim(),
]
})
)
return {
...metadata,
content: match[2].trim(),
}
}
This deliberately supports a tiny metadata format. If you need full YAML, use a maintained YAML parser instead of growing this function into one.
JSON.stringify() is doing important work. It safely turns data into valid JavaScript string literals.
Import a note
Create src/welcome.note:
---
title: Hello Vite
author: Flavio
---
This content came from a .note file.
Use it in src/main.js:
import note from './welcome.note'
document.querySelector('#app').innerHTML = `
<article>
<h1>${note.title}</h1>
<p>${note.content}</p>
<small>By ${note.author}</small>
</article>
`
Run:
npm run dev
Vite finds the import, loads the file, passes it through our plugin, and sends the resulting JavaScript module to the browser.
Edit the note. Vite sees the file change and updates the module through hot module replacement.
Add a virtual module
A virtual module is generated by a plugin and has no file on disk.
The usual convention uses:
- a public ID such as
virtual:notes - an internal resolved ID beginning with
\0
Extend the plugin:
export function notePlugin() {
const publicId = 'virtual:notes'
const resolvedId = `\0${publicId}`
const notes = new Map()
return {
name: 'vite-plugin-note',
resolveId(id) {
if (id === publicId) {
return resolvedId
}
},
load(id) {
if (id === resolvedId) {
return `export default ${JSON.stringify(
Array.from(notes.values())
)}`
}
},
transform(source, id) {
if (!id.endsWith('.note')) {
return
}
const note = parseNote(source, id)
notes.set(id, note)
return {
code: `export default ${JSON.stringify(note)}`,
map: null,
}
},
}
}
Now this works:
import notes from 'virtual:notes'
console.log(notes)
There is a subtle limitation: the virtual module only knows about .note files Vite has already transformed. A production-ready content index should scan a configured directory during startup, watch it, and invalidate the virtual module when files change.
That distinction matters. Plugins should not imply stronger behavior than they implement.
Add plugin options
Let people choose the file extension:
export function notePlugin(options = {}) {
const extension = options.extension || '.note'
return {
name: 'vite-plugin-note',
transform(source, id) {
const cleanId = id.split('?')[0]
if (!cleanId.endsWith(extension)) {
return
}
const note = parseNote(source, cleanId)
return {
code: `export default ${JSON.stringify(note)}`,
map: null,
}
},
}
}
Vite module IDs can contain query parameters, so compare against the clean path.
Configuration becomes:
notePlugin({
extension: '.note',
})
Validate options at startup and give an actionable error. A mysterious transform failure much later is harder to debug.
Development and production
Vite uses plugins during the development server and production build.
Our transform works in both because it converts one module to another without relying on the dev server.
If a feature should run only for one command, use:
{
name: 'vite-plugin-note',
apply: 'build',
}
Or inspect the resolved configuration:
let command
return {
name: 'vite-plugin-note',
configResolved(config) {
command = config.command
},
}
Do not assume a plugin that works under vite dev also works in vite build. Always test both.
Test the plugin through Vite
Parser unit tests are useful, but they do not prove the hooks work together.
Vite exposes a programmatic API:
import { afterAll, describe, expect, test } from 'vitest'
import { createServer } from 'vite'
import { notePlugin } from './vite-plugin-note.js'
let server
afterAll(async () => {
await server?.close()
})
describe('notePlugin', () => {
test('transforms a note into a module', async () => {
server = await createServer({
logLevel: 'silent',
plugins: [notePlugin()],
server: {
middlewareMode: true,
},
})
const result = await server.transformRequest(
'/src/welcome.note'
)
expect(result.code).toContain('"title":"Hello Vite"')
})
})
Also build a small fixture project in CI. That catches production-only problems and package export mistakes.
Debug plugin order
Several plugins can handle the same module.
Vite supports enforce: 'pre' and enforce: 'post', but do not add them automatically. Use ordering only when the plugin genuinely must run before or after normal transforms.
Install vite-plugin-inspect in a demo project when you need to see:
- which plugins transformed a module
- in which order they ran
- what code each transform produced
Good errors should include the plugin name and file:
[vite-plugin-note] Invalid metadata in /src/welcome.note
Package the plugin
For an npm package:
- make the plugin factory the public export
- use a
vite-plugin-name when appropriate - put
viteinpeerDependencies - document supported Vite versions
- include TypeScript declarations
- test the packed tarball in a fixture project
Vite 8 uses Rolldown internally, and the plugin API follows the familiar Rollup-style model while adding Vite-specific hooks. Prefer documented Vite hooks and avoid depending on internal module-graph details.
The three core hooks are now clear:
resolveIdsays what an import points toloadsupplies code for a resolved moduletransformchanges code that was loaded
Most plugins are a focused combination of those ideas. The best ones handle one format or workflow well, stay out of unrelated modules, and behave the same in development and production.
Related posts about js: