A LiveView-like experience for Phoenix using Datastar's SSE + Signals architecture.
This is still in alpha, I'm figuring out the right apis. Comments and ideas welcome.
Build interactive Phoenix applications with Datastar's simplicity: SSE instead of WebSockets, hypermedia over JSON, and a focus on performance.
Installation
With Igniter
If you have Igniter installed, run:
mix igniter.install phoenix_datastar
This will automatically:
- Add the Registry to your supervision tree
- Enable stripping of debug annotations in dev
- Add the Datastar JavaScript to your layout
- Import the router macro
- Add
live_datastaranddatastarhelpers to your web module
You'll then just need to add your routes (the installer will show you instructions).
Manual Installation
Add phoenix_datastar to your list of dependencies in mix.exs:
def deps do
[
{:phoenix_datastar, "~> 0.1.14"}
]
endThen follow the setup steps below.
1. Add Datastar to your layout
Include the Datastar JavaScript library in your layout's <head>:
<script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"
></script>2. Add to your supervision tree
In your application.ex:
children = [
# ... other children
{Registry, keys: :unique, name: PhoenixDatastar.Registry},
# ... rest of your children
]3. Import the router macro
In your router:
import PhoenixDatastar.Router
scope "/", MyAppWeb do
pipe_through :browser
datastar "/counter", CounterStar
endFor session-aware live navigation, add global Datastar endpoints and group routes with datastar_session.
The /__datastar scope is added automatically by mix phoenix_datastar.install:
import PhoenixDatastar.Router
# Global Datastar endpoints for SSE streaming and soft navigation.
# These need session access for CSRF protection.
scope "/__datastar" do
pipe_through [:fetch_session, :protect_from_forgery]
get "/stream", PhoenixDatastar.StreamPlug, :stream
post "/nav", PhoenixDatastar.NavPlug, :navigate
end
scope "/", MyAppWeb do
pipe_through [:browser, :require_user]
datastar_session :dashboard,
root_selector: "#dashboard-root" do
datastar "/dashboard", DashboardStar
datastar "/dashboard/orgs", DashboardOrgsStar
end
end4. Create :live_datastar and :datastar in your _web.ex
defmodule MyAppWeb do
#... existing calls
def live_datastar do
quote do
use PhoenixDatastar, :live
import PhoenixDatastar.Actions
unquote(html_helpers())
end
end
def datastar do
quote do
use PhoenixDatastar
import PhoenixDatastar.Actions
unquote(html_helpers())
end
end
end5. Strip debug annotations in dev (optional)
In your config/dev.exs, enable stripping of LiveView debug annotations from SSE patches:
config :phoenix_datastar, :strip_debug_annotations, trueThis removes <!-- @caller ... --> comments and data-phx-loc attributes from SSE patches. The initial page load keeps annotations intact for debugging.
6. Customize the mount template (optional)
PhoenixDatastar ships with a built-in mount template (PhoenixDatastar.DefaultHTML) that wraps your view content with the necessary Datastar signals and SSE initialization. You don't need to create your own — it works out of the box.
The default template automatically:
- Injects
session_idas a Datastar signal - Initializes all signals set via
put_signalinmount/3as Datastar signals (via@initial_signals) - Sets up the SSE stream connection for live views
If you need to customize it (e.g., add classes, extra attributes, or additional markup), create your own module:
defmodule MyAppWeb.DatastarHTML do
use Phoenix.Component
def mount(assigns) do
~H"""
<div
id="app"
class="my-wrapper"
data-signals={
Jason.encode!(
Map.merge(@initial_signals, %{
session_id: @session_id,
event_path: @event_path,
nav_path: @nav_path,
nav_token: @nav_token
})
)
}
data-init__once={@stream_path && "@get('#{@stream_path}', {openWhenHidden: true})"}
>
{@inner_html}
</div>
"""
end
endAvailable assigns in the mount template:
@session_id— unique session identifier@initial_signals— map of signals set viaput_signalinmount/3@stream_path— SSE stream URL (nil for stateless views)@event_path— event POST URL@nav_path— soft navigation POST URL@nav_token— signed stream/nav token@inner_html— the rendered view content
Then configure it in config/config.exs:
config :phoenix_datastar, :html_module, MyAppWeb.DatastarHTMLOr per-route:
datastar "/custom", CustomStar, html_module: MyAppWeb.DatastarHTMLUsage
Assigns vs Signals
PhoenixDatastar separates server-side state from client-side reactive state:
Assigns (
assign/2,3,update/3) are server-side state. They are available in templates as@keyand are never sent to the client. Use them for structs, DB records, or any data the server needs to remember or render HTML with.Signals (
put_signal/2,3,update_signal/3) are Datastar reactive state sent to the client via SSE. They must be JSON-serializable. The client accesses them via Datastar expressions like$count. Signals are not available as@keyin templates — Datastar handles their rendering client-side.
Client signals arrive as the payload argument in handle_event/3. They are untrusted input — read, validate, and explicitly put_signal what you want to send back.
Basic Example: Signals
The simplest pattern uses Datastar signals for all client state. The count lives entirely in signals — Datastar renders it client-side via data-text="$count":
defmodule MyAppWeb.CounterStar do
use MyAppWeb, :datastar
# or: use PhoenixDatastar
@impl PhoenixDatastar
def mount(_params, _session, socket) do
{:ok, put_signal(socket, :count, 0)}
end
@impl PhoenixDatastar
def handle_event("increment", payload, socket) do
count = payload["count"] || 0
{:noreply, put_signal(socket, :count, count + 1)}
end
@impl PhoenixDatastar
def render(assigns) do
~H"""
<div>
Count: <span data-text="$count"></span>
<button data-on:click={event("increment")}>+</button>
</div>
"""
end
endServer-Rendered Patches with Assigns
For more complex rendering, use assigns for server-side state and patch_elements to push HTML updates. This is useful when you need HEEx templates, loops, or conditional logic that's easier to express server-side:
defmodule MyAppWeb.ItemsStar do
use MyAppWeb, :live_datastar
# or: use PhoenixDatastar, :live
@impl PhoenixDatastar
def mount(_params, _session, socket) do
{:ok, assign(socket, items: ["Alpha", "Bravo"])}
end
@impl PhoenixDatastar
def handle_event("add", %{"name" => name}, socket) do
{:noreply,
socket
|> update(:items, &(&1 ++ [name]))
|> patch_elements("#items", &render_items/1)}
end
@impl PhoenixDatastar
def render(assigns) do
~H"""
<div>
<ul id="items">
<li :for={item <- @items}>{item}</li>
</ul>
<button data-on:click={event("add", "name: $newItem")}>Add</button>
</div>
"""
end
defp render_items(assigns) do
~H"""
<ul id="items">
<li :for={item <- @items}>{item}</li>
</ul>
"""
end
endTip: You can combine both patterns — use
put_signalfor simple reactive values (toggles, counters, form inputs) andassign+patch_elementsfor complex server-rendered sections.
The Lifecycle
PhoenixDatastar uses a hybrid of request/response and streaming:
- Initial Page Load (HTTP):
GET /countercallsmount/3andrender/1, returns full HTML - SSE Connection:
GET /__datastar/stream?token=...opens a persistent connection, starts or reuses a GenServer (live views only) - User Interactions:
POST /counter/_event/:eventtriggershandle_event/3, updates pushed via SSE (live) or returned directly (stateless)
Session Navigation (alpha)
When routes are grouped under the same datastar_session, you can navigate between them without a full page reload:
- Client clicks a
<.ds_link>or callsnavigate("/path"). POST /__datastar/navis sent with the signednav_token(included automatically by Datastar as a signal).PhoenixDatastar.NavPlugverifies the token, matches the target route viaPhoenixDatastar.RouteRegistry, and checks the target is a live view in the same session.- If valid:
Server.navigate/5swaps the view in the existing GenServer, pushes new HTML + signals +pushStatethrough the SSE stream. A freshnav_tokenis issued. - If invalid (different session, stateless target, or unknown route): falls back to
window.locationfor a full page reload.
Note: Soft navigation only works between live views (use PhoenixDatastar, :live) within the same datastar_session. Stateless views always trigger a full page reload.
Key Modules
PhoenixDatastar.StreamPlug— HandlesGET /__datastar/stream?token=.... Verifies the stream token, subscribes to the session's GenServer, and enters the SSE loop.PhoenixDatastar.NavPlug— HandlesPOST /__datastar/nav. Verifies the nav token, matches the target route, and either performs soft navigation or falls back to a full reload.- StreamToken — Signs and verifies Phoenix tokens for stream/nav authorization. Token expiry defaults to 1 hour, configurable via
config :phoenix_datastar, :stream_token_max_age, 3600. PhoenixDatastar.RouteRegistry— Runtime route lookup using metadata compiled bydatastar/3.
Callbacks
| Callback | Purpose |
|---|---|
mount/3 | Initialize state on page load |
handle_event/3 | React to user actions |
handle_info/2 | Handle PubSub messages, timers, etc. |
render/1 | Render the full component |
terminate/1 | Cleanup on disconnect (optional) |
Socket API
Assigns (server-side state)
# Assign values (server-side only, available as @key in templates)
socket = assign(socket, :user, current_user)
socket = assign(socket, items: [], loading: true)
# Update with a function
socket = update(socket, :count, &(&1 + 1))Signals (client-side Datastar state)
# Set signals (sent to client, accessed as $key in Datastar expressions)
socket = put_signal(socket, :count, 0)
socket = put_signal(socket, count: 0, name: "test")
# Update a signal with a function
socket = update_signal(socket, :count, &(&1 + 1))DOM Patches
# Queue a DOM patch (sent via SSE)
socket = patch_elements(socket, "#selector", &render_fn/1)
socket = patch_elements(socket, "#selector", ~H|<span>html</span>|)Scripts and Navigation
# Execute JavaScript on the client
socket = execute_script(socket, "alert('Hello!')")
socket = execute_script(socket, "console.log('debug')", auto_remove: false)
# Redirect the client
socket = redirect(socket, "/dashboard")
# Log to the browser console
socket = console_log(socket, "Debug message")
socket = console_log(socket, "Warning!", level: :warn)Action Helpers
PhoenixDatastar provides helper functions to simplify generating Datastar action expressions in your templates.
Requirements
- A
<meta name="csrf-token">tag must be present in your layout (Phoenix includes this by default)
event/1,2
Generates a Datastar @post action for triggering server events.
The generated expression uses $session_id and $event_path Datastar signals
(automatically initialized by DefaultHTML), so it works in any component
without needing to pass framework assigns through.
# Simple event
<button data-on:click={event("increment")}>+1</button>
# Event with options
<button data-on:click={event("toggle_code", "name: 'counter'")}>Toggle</button>
# With signals
<button data-on:click={event("update", "value: $count")}>Update</button>navigate/1,2 and <.ds_link>
Generates a Datastar @post expression for in-session soft navigation. The $nav_token signal is automatically included by Datastar in the request — no manual setup required.
<button data-on:click={navigate("/dashboard/orgs")}>Go to orgs</button>
<button data-on:click={navigate("/dashboard/orgs", replace: true)}>Replace history</button><.ds_link> wraps this in an anchor tag with a normal href fallback for accessibility, right-click, and modified clicks (Ctrl/Cmd+click open in new tab):
<.ds_link navigate="/dashboard/orgs">Organizations</.ds_link>
<.ds_link navigate="/dashboard/orgs" replace>Organizations</.ds_link>
<.ds_link navigate="/other" method={:hard}>Full Reload</.ds_link>The :method attribute (:soft or :hard, default :soft) lets you force a full page navigation when needed (e.g., switching workspaces).
Stateless vs Live Views
# Stateless view - no persistent connection, events handled synchronously
use MyAppWeb, :datastar
# or: use PhoenixDatastar
# Live view - persistent SSE connection with GenServer state
use MyAppWeb, :live_datastar
# or: use PhoenixDatastar, :liveStateless views handle events synchronously — state is restored by calling mount/3 on each request, client signals arrive in the payload, and the response is returned immediately. No GenServer or SSE connection is maintained.
Live views maintain a GenServer and SSE connection. Use :live when you need:
- Real-time updates from the server (PubSub, timers)
- Persistent server-side state across interactions
handle_info/2callbacks
Tips
Showing Flash Messages
Phoenix's built-in flash system (put_flash/3) doesn't work with PhoenixDatastar since there's no LiveView process managing flash state. Instead, use assign to set flash data and patch_elements to render the flash group component from your layout:
def handle_event("save", _payload, socket) do
# ... save logic ...
socket = assign(socket, flash: %{"info" => "Saved successfully!"})
{:noreply, patch_elements(socket, "#flash-group", &render_flash_group/1)}
end
def handle_info(:show_flash, socket) do
socket = assign(socket, flash: %{"info" => "hello world"})
{:noreply, patch_elements(socket, "#flash-group", &render_flash_group/1)}
end
defp render_flash_group(assigns) do
~H"""
<Layouts.flash_group flash={@flash} />
"""
endMake sure your layout's flash group has the #flash-group ID so the patch selector can target it.
Links
- Datastar - The frontend library this integrates with
- Phoenix LiveView - The inspiration for the callback design