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 the note files configured for the project.

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'

const article = document.createElement('article')
const title = document.createElement('h1')
const content = document.createElement('p')
const author = document.createElement('small')

title.textContent = note.title
content.textContent = note.content
author.textContent = `By ${note.author}`

article.append(title, content, author)
document.querySelector('#app').replaceChildren(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 reloads the page. A larger application can add an explicit HMR boundary to preserve state during the update.

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. The virtual module imports each configured note, so it works regardless of module load order:

export function notePlugin(options = {}) {
  const publicId = 'virtual:notes'
  const resolvedId = `\0${publicId}`
  const extension = options.extension || '.note'
  const noteFiles = options.notes || []

  if (typeof extension !== 'string' || !extension.startsWith('.')) {
    throw new TypeError(
      '[vite-plugin-note] extension must start with a dot'
    )
  }

  if (
    !Array.isArray(noteFiles) ||
    noteFiles.some((file) => typeof file !== 'string')
  ) {
    throw new TypeError(
      '[vite-plugin-note] notes must be an array of paths'
    )
  }

  return {
    name: 'vite-plugin-note',

    resolveId(id) {
      if (id === publicId) {
        return resolvedId
      }
    },

    load(id) {
      if (id === resolvedId) {
        const imports = noteFiles
          .map((file, index) => {
            return `import note${index} from ${JSON.stringify(file)}`
          })
          .join('\n')

        const names = noteFiles
          .map((_, index) => `note${index}`)
          .join(', ')

        return `${imports}\nexport default [${names}]`
      }
    },

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

Now this works:

import notes from 'virtual:notes'

console.log(notes)

Configure the files in vite.config.js:

notePlugin({
  notes: ['/src/welcome.note'],
})

Using imports inside the virtual module also puts the note files in Vite’s module graph. Changes take part in normal development updates and production builds.

Add plugin options

The factory now lets people choose the file extension:

notePlugin({
  extension: '.note',
  notes: ['/src/welcome.note'],
})

Vite module IDs can contain query parameters, so the transform compares the extension against the clean path.

Validate both 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:

npm install --save-dev vitest

Add "test": "vitest run" to the scripts in package.json.

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({
      configFile: false,
      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: