Phoenix Vapor

Vue template syntax compiled to native %Phoenix.LiveView.Rendered{} structs via Rust NIFs. Four progressive modes from zero-JS templates to full hybrid reactivity.

Modes

Mode What Client JS
~VUE sigil Vue templates in any LiveView 0 KB
.vue Reactive SFC with server-side reactivity (QuickBEAM) 0 KB
.vue Hybrid Split reactivity — server owns data, client owns UI ~50 KB (Vue 3)
Full Vue Runtime Third-party Vue component libraries server-side 0 KB

~VUE Sigil

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view
  use PhoenixVapor

  def mount(_params, _session, socket), do: {:ok, assign(socket, count: 0)}

  def render(assigns) do
    ~VUE"""
    <div>
      <p>{{ count }}</p>
      <button @click="inc">+</button>
    </div>
    """
  end

  def handle_event("inc", _, socket), do: {:noreply, update(socket, :count, &(&1 + 1))}
end

Same WebSocket, same diff protocol, same LiveView client. No wrapper divs, no phx-update="ignore".

Supported syntax: {{ expr }} · :attr="expr" · @click · v-if / v-else-if / v-else · v-for · v-show · v-model · v-html · ternaries · arithmetic · .length · .toUpperCase() · dot access · components.

Reactive Mode

<script setup>
import { ref, computed } from "vue"
const count = ref(0)
const doubled = computed(() => count * 2)
function increment() { count++ }
</script>

<template>
  <p>{{ count }} × 2 = {{ doubled }}</p>
  <button @click="increment">+</button>
</template>
defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view
  use PhoenixVapor, file: "Counter.vue", runtime: :reactive
end

ref() → server-side state in QuickBEAM, computed() → derived state, functions → event handlers. Three lines of Elixir.

Hybrid Mode

Server owns data (defineProps), client owns UI state (ref()). Search, sort, filter are instant — zero server round-trip.

<script setup>
import { ref, computed } from "vue"
const props = defineProps(["contacts"])
const search = ref("")
const filtered = computed(() =>
  (props.contacts || []).filter(c => c.name.toLowerCase().includes(search.value.toLowerCase()))
)
function deleteContact(id) {
  "use server"
  props.contacts = props.contacts.filter(c => c.id !== id)
}
</script>

<template>
  <input v-model="search" placeholder="Search..." />
  <p>{{ filtered.length }} of {{ props.contacts.length }} contacts</p>
  <div v-for="contact in filtered" :key="contact.id">
    {{ contact.name }}
    <button @click="deleteContact(contact.id)">×</button>
  </div>
</template>
defmodule MyAppWeb.ContactsLive do
  use MyAppWeb, :live_view
  use PhoenixVapor, file: "Contacts.vue"

  def mount(_params, _session, socket) do
    {:ok, assign(socket, contacts: Repo.all(Contact))}
  end

  def handle_event("deleteContact", %{"id" => id}, socket) do
    Repo.delete!(Contact, id)
    {:noreply, assign(socket, contacts: Repo.all(Contact))}
  end
end

The compiler auto-classifies bindings: defineProps → server, ref() → client, "use server" → server action. See docs/hybrid-architecture.md.

Custom Elixir Code

The hybrid module is a normal LiveView. The macro only generates render/1 and fallback event handlers — everything else is yours:

defmodule MyAppWeb.ContactsLive do
  use MyAppWeb, :live_view
  use PhoenixVapor, file: "Contacts.vue"

  # Standard LiveView callbacks — write whatever you need
  def mount(_params, _session, socket) do
    {:ok, assign(socket, contacts: Repo.all(Contact))}
  end

  # Your handle_event takes precedence over the generated fallback
  def handle_event("deleteContact", %{"id" => id}, socket) do
    Repo.delete!(Contact, id)
    {:noreply, assign(socket, contacts: Repo.all(Contact))}
  end

  # handle_info, handle_params, etc. all work normally
  def handle_info({:contact_created, contact}, socket) do
    {:noreply, assign(socket, contacts: [contact | socket.assigns.contacts])}
  end
end

The "use server" directive in the .vue file only defines which event names the client can push and generates pushEvent stubs in the client JS. The actual server logic is Elixir you write yourself. If you don't write a handle_event for a given name, a no-op fallback is generated.

Single-File Mode (<script lang="elixir">)

For convenience, you can embed Elixir code directly in the .vue file. Everything in one place, at the cost of losing Elixir IDE support for that block:

<script lang="elixir">
def mount(_params, _session, socket) do
  {:ok, assign(socket, contacts: Repo.all(Contact))}
end

def handle_event("deleteContact", %{"id" => id}, socket) do
  Repo.delete!(Contact, id)
  {:noreply, assign(socket, contacts: Repo.all(Contact))}
end
</script>

<script setup>
import { ref, computed } from "vue"
const props = defineProps(["contacts"])
const search = ref("")
const filtered = computed(() =>
  (props.contacts || []).filter(c => c.name.includes(search.value))
)
function deleteContact(id) {
  "use server"
}
</script>

<template>
  <input v-model="search" />
  <div v-for="c in filtered">{{ c.name }}</div>
</template>
defmodule MyAppWeb.ContactsLive do
  use MyAppWeb, :live_view
  use PhoenixVapor, file: "Contacts.vue"
end

The <script lang="elixir"> block is extracted and injected into the LiveView module at compile time. The Elixir block is stripped before Vue compilation — Vize and Volt never see it.

Installation

def deps do
  [
    {:phoenix_vapor, "~> 0.2.0"},
    {:quickbeam, "~> 0.10.0", optional: true},
    {:volt, "~> 0.10.0", optional: true}
  ]
end

Toolchain

All compilation runs through Rust NIFs and the BEAM — no Node.js required.

Tool Role
Vize Vue SFC → Vapor IR / standard render functions
OXC JS/TS parse, transform, bundle, format, lint
QuickBEAM Server-side JS runtime (Vue reactivity, complex expressions)
Volt Dev server, HMR, Tailwind, production builds

Docs

License

MIT