Hex.pm Documentation

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_datastar and datastar helpers 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"}
  ]
end

Then 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
end

For 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
end

4. 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
end

5. 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, true

This 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_id as a Datastar signal
  • Initializes all signals set via put_signal in mount/3 as 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
end

Available assigns in the mount template:

  • @session_id — unique session identifier
  • @initial_signals — map of signals set via put_signal in mount/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.DatastarHTML

Or per-route:

datastar "/custom", CustomStar, html_module: MyAppWeb.DatastarHTML

Usage

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 @key and 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 @key in 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
end

Server-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
end

Tip: You can combine both patterns — use put_signal for simple reactive values (toggles, counters, form inputs) and assign + patch_elements for complex server-rendered sections.

The Lifecycle

PhoenixDatastar uses a hybrid of request/response and streaming:

  1. Initial Page Load (HTTP): GET /counter calls mount/3 and render/1, returns full HTML
  2. SSE Connection: GET /__datastar/stream?token=... opens a persistent connection, starts or reuses a GenServer (live views only)
  3. User Interactions: POST /counter/_event/:event triggers handle_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:

  1. Client clicks a <.ds_link> or calls navigate("/path").
  2. POST /__datastar/nav is sent with the signed nav_token (included automatically by Datastar as a signal).
  3. PhoenixDatastar.NavPlug verifies the token, matches the target route via PhoenixDatastar.RouteRegistry, and checks the target is a live view in the same session.
  4. If valid: Server.navigate/5 swaps the view in the existing GenServer, pushes new HTML + signals + pushState through the SSE stream. A fresh nav_token is issued.
  5. If invalid (different session, stateless target, or unknown route): falls back to window.location for 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 — Handles GET /__datastar/stream?token=.... Verifies the stream token, subscribes to the session's GenServer, and enters the SSE loop.
  • PhoenixDatastar.NavPlug — Handles POST /__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 by datastar/3.

Callbacks

CallbackPurpose
mount/3Initialize state on page load
handle_event/3React to user actions
handle_info/2Handle PubSub messages, timers, etc.
render/1Render the full component
terminate/1Cleanup 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>

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, :live

Stateless 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/2 callbacks

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} />
  """
end

Make sure your layout's flash group has the #flash-group ID so the patch selector can target it.