Skip to content
FLAVIO COPES
flaviocopes.com

Build a Vite plugin from scratch

By

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:

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:

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:

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:

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:

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: