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
endref() → 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}
]
endToolchain
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
- Architecture — how each mode works at the protocol level
- Hybrid Architecture — the split-reactivity design
- Wire Protocol Comparison — PhoenixVapor vs Fronix/LiveVue
- Hologram Comparison — PhoenixVapor vs Hologram
- examples/demo — runnable Phoenix app with all modes
License
MIT