# 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
```elixir
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
```vue
<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>
```
```elixir
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.
```vue
<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>
```
```elixir
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](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:
```elixir
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:
```vue
<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>
```
```elixir
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
```elixir
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](https://hex.pm/packages/vize) | Vue SFC → Vapor IR / standard render functions |
| [OXC](https://hex.pm/packages/oxc) | JS/TS parse, transform, bundle, format, lint |
| [QuickBEAM](https://hex.pm/packages/quickbeam) | Server-side JS runtime (Vue reactivity, complex expressions) |
| [Volt](https://hex.pm/packages/volt) | Dev server, HMR, Tailwind, production builds |
## Docs
- [Architecture](ARCHITECTURE.md) — how each mode works at the protocol level
- [Hybrid Architecture](docs/hybrid-architecture.md) — the split-reactivity design
- [Wire Protocol Comparison](docs/comparisons/fronix-wire-protocol.md) — PhoenixVapor vs Fronix/LiveVue
- [Hologram Comparison](docs/comparisons/hologram.md) — PhoenixVapor vs Hologram
- [examples/demo](examples/demo) — runnable Phoenix app with all modes
## License
MIT