Built-in Plugins

Volt includes built-in support for Vue, Svelte, and React. These are activated automatically when you import .vue or .svelte files, or configure import_source: "react".

Using Plugins

Add plugins to your Volt config:

config :volt, plugins: [MyApp.MarkdownPlugin]

Plugins can also accept options as {module, opts} tuples:

config :volt, plugins: [{MyApp.SassPlugin, output_style: :compressed}]

Plugin ordering

Plugins run in Vite-style phases. By default, plugins run in the order they are configured. Return :pre or :post from enforce/0 to run before or after normal plugins:

defmodule MyApp.PrePlugin do
  @behaviour Volt.Plugin

  def name, do: "my-pre-plugin"
  def enforce, do: :pre
end

The ordering applies consistently to resolve, load, compile, transform, define, prebundle, and render hooks.

Writing Plugins

Implement the Volt.Plugin behaviour. All callbacks except name/0 are optional — implement only the hooks you need.

Example: Markdown imports

Load .md files as HTML string modules:

defmodule MyApp.MarkdownPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "markdown"

  @impl true
  def resolve(spec, _importer) do
    if String.ends_with?(spec, ".md"), do: {:ok, spec}
  end

  @impl true
  def load(path) do
    if String.ends_with?(path, ".md") do
      html = path |> File.read!() |> Earmark.as_html!()
      {:ok, "export default #{Jason.encode!(html)};\n"}
    end
  end

  def resolve(_, _), do: nil
  def load(_), do: nil
end
import readme from './README.md'
document.getElementById('content').innerHTML = readme

Example: Banner injection

Use render_chunk/2 to prepend a license banner to production output:

defmodule MyApp.BannerPlugin do
  @behaviour Volt.Plugin

  @banner "/* © 2026 MyApp — MIT License */\n"

  @impl true
  def name, do: "banner"

  @impl true
  def render_chunk(code, %{type: :entry}), do: {:ok, @banner <> code}
  def render_chunk(_code, _chunk_info), do: nil
end

Example: Compile-time constants

Use define/1 to inject build-time values:

defmodule MyApp.BuildInfo do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "build-info"

  @impl true
  def define(_mode) do
    {hash, 0} = System.cmd("git", ["rev-parse", "--short", "HEAD"])

    %{
      "__BUILD_HASH__" => Jason.encode!(String.trim(hash)),
      "__BUILD_TIME__" => Jason.encode!(DateTime.utc_now() |> to_string())
    }
  end
end
console.log(`Build ${__BUILD_HASH__} at ${__BUILD_TIME__}`)

Example: CSS compilation

Use compile/3 to return both a JavaScript module and extracted CSS. Volt will inject the CSS in dev, collect it into the production CSS file, and run parser-backed asset URL rewriting on the final CSS output:

defmodule MyApp.SassPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "sass"

  @impl true
  def extensions(:compile), do: [".scss"]
  def extensions(:resolve), do: [".scss"]
  def extensions(:watch), do: [".scss"]
  def extensions(_), do: []

  @impl true
  def resolve(spec, _importer) do
    if String.ends_with?(spec, ".scss"), do: {:ok, spec}
  end

  def resolve(_, _), do: nil

  @impl true
  def compile(path, source, _opts) do
    if Path.extname(path) == ".scss" do
      case MyApp.Sass.compile(source, filename: path) do
        {:ok, css} ->
          {:ok, %{code: "export default undefined;\n", css: css, sourcemap: nil, hashes: nil}}

        {:error, _} = error ->
          error
      end
    end
  end
end
import './theme.scss'

If the generated CSS contains relative asset URLs such as url('./logo.svg'), Volt rewrites them through the same asset pipeline as normal CSS files.

Example: Custom file compilation with OXC templates

Use compile/3 to handle a custom file format. OXC templates let you generate JavaScript from real JavaScript syntax instead of string concatenation:

defmodule MyApp.CSVPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "csv"

  @impl true
  def extensions(:compile), do: [".csv"]
  def extensions(:resolve), do: [".csv"]
  def extensions(_), do: []

  @impl true
  def resolve(spec, _importer) do
    if String.ends_with?(spec, ".csv"), do: {:ok, spec}
  end

  def resolve(_, _), do: nil

  @impl true
  def compile(path, source, _opts) do
    if Path.extname(path) == ".csv" do
      rows =
        source
        |> String.split("\n", trim: true)
        |> Enum.map(&String.split(&1, ","))

      js =
        "export default $rows;"
        |> OXC.parse!("csv-template.js")
        |> OXC.bind(rows: {:literal, rows})
        |> OXC.codegen!()

      {:ok, %{code: js, sourcemap: nil, css: nil, hashes: nil}}
    end
  end
end
import data from './prices.csv'
// data = [["name", "price"], ["Widget", "9.99"], ...]

For larger generated modules, keep the template in a .js file so editors and formatters understand it:

// priv/templates/api-client.js
export const $name = {
  endpoint: $endpoint,
  columns: $columns,
}
template = File.read!("priv/templates/api-client.js")

js =
  template
  |> OXC.parse!("api-client-template.js")
  |> OXC.bind(
    name: "prices",
    endpoint: {:literal, "/api/prices"},
    columns: {:literal, ["name", "price"]}
  )
  |> OXC.codegen!()

Use OXC.splice/3 when a placeholder represents multiple syntax nodes, such as object entries or function arguments. Volt uses this pattern internally for import.meta.glob() output generation.

Example: AST transform with OXC

Use transform/2 to modify compiled JavaScript. OXC provides parse/2, postwalk/3, and patch_string/2 for AST-based transforms:

defmodule MyApp.StripConsolePlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "strip-console"

  @impl true
  def transform(code, _path) do
    case OXC.parse(code, "module.js") do
      {:ok, ast} ->
        patches =
          collect_console_calls(ast)
          |> Enum.map(fn %{start: s, end: e} ->
            %{start: s, end: e, change: "void 0"}
          end)

        if patches == [] do
          nil
        else
          {:ok, OXC.patch_string(code, patches)}
        end

      {:error, _} ->
        nil
    end
  end

  defp collect_console_calls(ast) do
    {_ast, calls} =
      OXC.postwalk(ast, [], fn
        %{
          type: :call_expression,
          callee: %{
            type: :member_expression,
            object: %{type: :identifier, name: "console"}
          }
        } = node, acc ->
          {node, [node | acc]}

        node, acc ->
          {node, acc}
      end)

    calls
  end
end

Hooks

All hooks are optional. Return nil to pass to the next plugin.

HookPurpose
name/0Plugin identifier (required)
extensions/1File extensions for :compile, :resolve, :watch, or :scan
resolve/2Resolve import specifiers to file paths
load/1Load file content for a resolved path
compile/3Compile source into browser-ready JS + optional CSS
extract_imports/3Extract import specifiers from source
transform/2Transform compiled JS before serving or bundling
define/1Compile-time variable replacements
prebundle_alias/1Canonical prebundle specifier for an import
prebundle_entry/1Generated prebundle entry module
render_chunk/2Transform final output chunks

Hook execution order

During compilation, hooks run in this order:

  1. resolve — map import specifier to a file path
  2. load — read file content (override File.read)
  3. compile — transform source into JS + CSS
  4. extract_imports — find imports for dependency walking
  5. transform — post-process compiled JS
  6. render_chunk — modify final bundled output

define/1 runs once at build start. extensions/1 is checked throughout to determine which files a plugin handles.

Plugin options

When configured as a {module, opts} tuple, the opts are passed as an extra argument to callbacks that support it. Define a callback with one additional arity to receive them:

defmodule MyApp.SassPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "sass"

  # 3-arg version (standard)
  def compile(path, source, opts), do: compile(path, source, opts, [])

  # 4-arg version receives plugin opts
  def compile(path, source, opts, plugin_opts) do
    style = Keyword.get(plugin_opts, :output_style, :expanded)
    # ...
  end
end

JavaScript Runtimes

Plugins can run JavaScript build tools through Volt.JS.Runtime, which installs npm packages into Volt's cache and executes them in QuickBEAM without requiring Node.js in the host application. This is useful for CSS preprocessors and other tools that do not have a native Elixir or Rust binding:

defmodule MyApp.SassPlugin do
  @behaviour Volt.Plugin

  @runtime_name __MODULE__.Runtime
  @runtime_packages %{"sass" => "^1.80.0"}

  @impl true
  def name, do: "sass"

  @impl true
  def extensions(:compile), do: [".scss"]
  def extensions(:resolve), do: [".scss"]
  def extensions(_), do: []

  @impl true
  def compile(path, source, _opts) do
    if Path.extname(path) == ".scss" do
      runtime =
        Volt.JS.Runtime.ensure!(
          name: @runtime_name,
          packages: @runtime_packages,
          entry: {:volt_asset, "sass-runtime.ts"},
          bundle: true
        )

      case Volt.JS.Runtime.call(runtime, "compileSass", [source, path]) do
        {:ok, %{"css" => css}} ->
          js = "var s = document.createElement('style'); s.textContent = #{Jason.encode!(css)}; document.head.appendChild(s);\n"
          {:ok, %{code: js, sourcemap: nil, css: css, hashes: nil}}

        {:error, _} = error ->
          error
      end
    end
  end
end

The runtime automatically installs npm packages on first use and caches the bundled entry script. Subsequent calls reuse the running QuickBEAM instance.