This guide walks you through migrating an existing PhoenixDatastar application to Dstar.
Why Migrate?
PhoenixDatastar is deprecated as of v0.2.0 — no further updates are planned. Dstar is its successor, built on the same SSE/Datastar foundations but with a radically simpler architecture:
| PhoenixDatastar | Dstar | |
|---|---|---|
| Architecture | Behaviours, GenServers, Socket structs, Registry, supervision trees | ~700 lines of pure functions |
| State | Socket with assigns + signals + event queue | Direct Plug.Conn — no wrapper structs |
| Routing | Custom macros (datastar_session, datastar) | Standard Phoenix routes |
| Views | Stateless + Live view modes via use macro | Plain controller actions |
| Real-time | Built-in GenServer per session | PubSub + receive loop (you own it) |
| Navigation | Soft nav system (NavPlug, RouteRegistry, tokens) | Standard links / Datastar client-side nav |
| Dependencies | Phoenix (full framework) | Plug + Jason only |
| Config | config :phoenix_datastar, Registry in supervision tree | Nothing — zero config |
The tradeoff is clear: PhoenixDatastar gave you more batteries (session management, soft navigation, process-backed state). Dstar gives you raw primitives and gets out of your way. You write less library code and more application code.
Step 1: Update Dependencies
# mix.exs
# Before
def deps do
[
{:phoenix_datastar, "~> 0.2.0"}
]
end
# After
def deps do
[
{:dstar, "~> 0.0.7"}
]
endRun mix deps.get.
Step 2: Remove PhoenixDatastar Infrastructure
Supervision tree
Remove the Registry from your application.ex:
# Before — application.ex
children = [
{Registry, keys: :unique, name: PhoenixDatastar.Registry},
# ...
]
# After — just remove the Registry line
children = [
# ...
]Configuration
Remove all config :phoenix_datastar entries from config/:
# Before — config.exs
config :phoenix_datastar,
html_module: MyAppWeb.DatastarHTML,
strip_debug_annotations: true,
stream_token_max_age: 3600
# After — delete these lines entirely. Dstar has no config.Web module helpers
Remove the datastar and live_datastar helper functions from your *_web.ex:
# Before — my_app_web.ex
def datastar do
quote do
use PhoenixDatastar
import PhoenixDatastar.Actions
unquote(html_helpers())
end
end
def live_datastar do
quote do
use PhoenixDatastar, :live
import PhoenixDatastar.Actions
unquote(html_helpers())
end
end
# After — delete both functions. You won't need them.Step 3: Rewrite Routes
PhoenixDatastar used custom router macros. Dstar uses standard Phoenix routes.
Basic routes
# Before — router.ex
import PhoenixDatastar.Router
scope "/__datastar" do
pipe_through [:fetch_session]
post "/stream", PhoenixDatastar.StreamPlug, :stream
end
scope "/__datastar" do
pipe_through [:fetch_session, :protect_from_forgery]
post "/nav", PhoenixDatastar.NavPlug, :navigate
end
scope "/", MyAppWeb do
pipe_through :browser
datastar_session :default do
datastar "/counter", CounterStar
datastar "/todos", TodoStar
end
end
# After — router.ex
scope "/", MyAppWeb do
pipe_through :browser
get "/counter", CounterController, :show
post "/counter/increment", CounterController, :increment
get "/todos", TodoController, :show
post "/todos/add", TodoController, :add
post "/todos/toggle", TodoController, :toggle
endThe /__datastar/* routes (stream, nav) are no longer needed — there are no session processes or soft navigation tokens.
Using dynamic dispatch (optional)
If you have many events per view, you can use Dstar.Plugs.Dispatch instead of individual routes:
# After (alternative) — router.ex
scope "/", MyAppWeb do
pipe_through :browser
get "/counter", CounterController, :show
get "/todos", TodoController, :show
end
# Single route handles all Datastar events
post "/ds/:module/:event", Dstar.Plugs.Dispatch, modules: [
MyAppWeb.CounterHandler,
MyAppWeb.TodoHandler
]Step 4: Convert Views to Controllers
This is the biggest change. PhoenixDatastar views are behaviour modules with callbacks. Dstar uses plain controller actions (or handler modules with handle_event/3).
Stateless views
# Before — counter_star.ex
defmodule MyAppWeb.CounterStar do
use MyAppWeb, :datastar
@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) + 1
{: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# After — counter_controller.ex
defmodule MyAppWeb.CounterController do
use MyAppWeb, :controller
def show(conn, _params) do
render(conn, :counter)
end
def increment(conn, _params) do
signals = Dstar.read_signals(conn)
count = (signals["count"] || 0) + 1
conn
|> Dstar.start()
|> Dstar.patch_signals(%{count: count})
end
end<%!-- After — counter.html.heex --%>
<div data-signals:count="0">
Count: <span data-text="$count"></span>
<button data-on:click="@post('/counter/increment')">+</button>
</div>Key differences:
- No
mount/3— initial state goes in the template viadata-signals - No
socket— work directly withconn - No
{:noreply, socket}tuples — just return the conn - No
put_signal/3— useDstar.patch_signals/3 - No
render/1callback — use standard Phoenix templates - No
event("name")— use@post('/path')orDstar.post(Module, "name")
Stateless views using dynamic dispatch
If you prefer handler modules over controller actions (closer to the PhoenixDatastar pattern):
# After (alternative) — counter_handler.ex
defmodule MyAppWeb.CounterHandler do
def handle_event(conn, "increment", signals) do
count = (signals["count"] || 0) + 1
conn
|> Dstar.start()
|> Dstar.patch_signals(%{count: count})
end
end<%!-- Template uses Dstar.post/2 for dispatch routing --%>
<div data-signals:count="0">
Count: <span data-text="$count"></span>
<button data-on:click={Dstar.post(MyAppWeb.CounterHandler, "increment")}>+</button>
</div>Note: The
handle_event/3signature is different — Dstar passes(conn, event, signals)instead of PhoenixDatastar's(event, payload, socket).
Live views (with PubSub)
Live views require the most thought. PhoenixDatastar managed a GenServer per session with persistent SSE. In Dstar, you manage the SSE loop yourself.
# Before — multiplayer_star.ex
defmodule MyAppWeb.MultiplayerStar do
use MyAppWeb, :live_datastar
@impl PhoenixDatastar
def mount(_params, _session, socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "game:updates")
{:ok, assign(socket, players: [])}
end
@impl PhoenixDatastar
def handle_event("join", %{"name" => name}, socket) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "game:updates", {:player_joined, name})
{:noreply, update(socket, :players, &[name | &1])}
end
@impl PhoenixDatastar
def handle_info({:player_joined, name}, socket) do
socket = update(socket, :players, &[name | &1])
{:noreply, patch_elements(socket, "#players", &render_players/1)}
end
@impl PhoenixDatastar
def render(assigns) do
~H"""
<div>
<div id="players"><.render_players players={@players} /></div>
<button data-on:click={event("join")}>Join</button>
</div>
"""
end
defp render_players(assigns) do
~H"<ul><li :for={p <- @players}>{p}</li></ul>"
end
end# After — split into controller + stream controller
# game_controller.ex (page load + events)
defmodule MyAppWeb.GameController do
use MyAppWeb, :controller
def show(conn, _params) do
render(conn, :game)
end
def join(conn, _params) do
signals = Dstar.read_signals(conn)
name = signals["name"]
Phoenix.PubSub.broadcast(MyApp.PubSub, "game:updates", {:player_joined, name})
conn
|> Dstar.start()
|> Dstar.patch_signals(%{joined: true})
end
end
# game_stream_controller.ex (persistent SSE connection)
defmodule MyAppWeb.GameStreamController do
use MyAppWeb, :controller
def stream(conn, _params) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "game:updates")
conn
|> Dstar.start()
|> loop()
end
defp loop(conn) do
receive do
{:player_joined, name} ->
html = Phoenix.Template.render_to_string(MyAppWeb.GameHTML, "players", %{name: name})
conn
|> Dstar.patch_elements(html, selector: "#players ul", mode: :append)
|> loop()
end
end
end<%!-- game.html.heex --%>
<div data-signals:name="''"
data-signals:joined="false"
data-init="@post('/game/stream', {retryMaxCount: Infinity})"
data-on:online__window="@post('/game/stream', {retryMaxCount: Infinity})">
<div id="players"><ul></ul></div>
<input data-bind:name placeholder="Your name" />
<button data-on:click="@post('/game/join')">Join</button>
</div># Routes
get "/game", GameController, :show
post "/game/join", GameController, :join
post "/game/stream", GameStreamController, :streamKey differences:
- No GenServer — the stream action is the long-lived process
- No
handle_info/2callback — use areceiveloop directly - No automatic keepalive — Datastar's
retryMaxCount: Infinityhandles reconnection - No session management — each stream connection is independent
assigns(server-side state) don't survive across requests — use the database, ETS, or PubSub to share state
Step 5: Update Templates
Signals initialization
<%!-- Before: signals were set in mount/3 via put_signal --%>
<%!-- The template just referenced them with $signal_name --%>
<%!-- After: initialize signals directly in the template --%>
<div data-signals:count="0"
data-signals:name="''"
data-signals:items="[]">
...
</div>Event bindings
<%!-- Before --%>
<button data-on:click={event("increment")}>+</button>
<button data-on:click={event("save", method: :put)}>Save</button>
<%!-- After — explicit routes --%>
<button data-on:click="@post('/counter/increment')">+</button>
<button data-on:click="@put('/items/save')">Save</button>
<%!-- After — using dynamic dispatch --%>
<button data-on:click={Dstar.post(MyAppWeb.CounterHandler, "increment")}>+</button>Navigation links
<%!-- Before — soft navigation --%>
<.ds_link navigate="/settings">Settings</.ds_link>
<button data-on:click={navigate("/settings")}>Go</button>
<%!-- After — standard links (full page load) --%>
<a href="/settings">Settings</a>
<%!-- After — Datastar client-side nav (if you want SPA-like behavior) --%>
<%!-- See Datastar docs for data-on:click with fetch + DOM swap patterns --%>Note: Dstar does not include a soft navigation system. If you relied heavily on PhoenixDatastar's in-session navigation, you'll need to either accept full page loads or implement client-side navigation yourself using Datastar's primitives.
Server-rendered patches
# Before — patch_elements on socket with render function
def handle_event("refresh", _payload, socket) do
{:noreply, patch_elements(socket, "#items", &render_items/1)}
end
defp render_items(assigns) do
~H"<ul id='items'><li :for={i <- @items}>{i}</li></ul>"
end
# After — patch_elements on conn with HTML string
def refresh(conn, _params) do
items = MyApp.Items.list()
html = Phoenix.Template.render_to_string(MyAppWeb.ItemHTML, "list", %{items: items})
conn
|> Dstar.start()
|> Dstar.patch_elements(html, selector: "#items")
endStep 6: Update CSRF Setup
Header-based approach (recommended)
<%!-- Before — Phoenix already exposes this in the layout head --%>
<meta name="csrf-token" content={get_csrf_token()} />
<%!-- After — keep using the standard Phoenix meta tag --%>
<meta name="csrf-token" content={get_csrf_token()} />When using Dstar's verb helpers (post/2,3, get/2,3, put/2,3, patch/2,3, delete/2,3), the CSRF header is automatically included in the generated expressions by reading the <meta name="csrf-token"> tag.
This means Datastar's normal signal round-tripping does not rewrite the CSRF header used by Dstar.Actions.
If you write @post(...) manually, add the header yourself:
<button data-on:click="@post('/counter/increment', {headers: {'x-csrf-token': document.querySelector('meta[name=csrf-token]').content}})">
+
</button>Form + SSE approach
# Before — router.ex
plug Dstar.Plugs.RenameCsrfParam
# After — same plug, same setup
plug Dstar.Plugs.RenameCsrfParamIf you expose the token as a non-prefixed csrf signal for form compatibility, Datastar will include it in every request body. That's expected, and Dstar.Plugs.RenameCsrfParam exists specifically to copy that value into _csrf_token for Plug.CSRFProtection.
Step 7: Update Script Execution & Redirects
The API is nearly identical, just swap socket for conn:
# Before
def handle_event("export", _payload, socket) do
{:noreply,
socket
|> execute_script("window.alert('Done!')")
|> redirect("/results")}
end
# After
def export(conn, _params) do
conn
|> Dstar.start()
|> Dstar.execute_script("window.alert('Done!')")
|> Dstar.redirect("/results")
endStep 8: Clean Up
- Delete old view modules (
*_star.exor similar) once they've been converted to controllers/handlers. - Delete any custom
DatastarHTMLmount template module — Dstar doesn't use one. - Remove
import PhoenixDatastar.Routerfrom your router. - Remove
import PhoenixDatastar.Actionsfrom any modules still referencing it. - Run
mix compile --warnings-as-errorsto catch any remaining references to PhoenixDatastar modules.
API Mapping Reference
| PhoenixDatastar | Dstar | Notes |
|---|---|---|
put_signal(socket, key, val) | Dstar.patch_signals(conn, %{key => val}) | Template data-signals for initial values |
put_signal(socket, map) | Dstar.patch_signals(conn, map) | |
update_signal(socket, key, fun) | Read + transform + Dstar.patch_signals | No shortcut — apply the function yourself |
assign(socket, key, val) | N/A | Use controller assigns or local variables |
update(socket, key, fun) | N/A | Use local variables |
patch_elements(socket, sel, fn) | Dstar.patch_elements(conn, html, selector: sel) | Render HTML before calling |
patch_elements(socket, sel, html) | Dstar.patch_elements(conn, html, selector: sel) | |
execute_script(socket, js) | Dstar.execute_script(conn, js) | |
redirect(socket, url) | Dstar.redirect(conn, url) | |
console_log(socket, msg) | Dstar.console_log(conn, msg) | |
event("name") | Dstar.post(Module, "name") or @post('/path') | All verbs available: get, put, patch, delete |
navigate("/path") | Standard <a href> | No soft nav in Dstar |
PhoenixDatastar.Router.datastar/3 | Standard get/post routes | |
PhoenixDatastar.Router.datastar_session/3 | N/A | No sessions in Dstar |
{:noreply, socket} | Return conn | |
{:stop, socket} | Return conn (connection closes naturally) | |
handle_info/2 callback | receive block in stream loop | |
terminate/1 callback | N/A (process cleanup is automatic) |
Common Patterns Comparison
Flash messages
# Before
def handle_event("save", _payload, socket) do
socket = assign(socket, flash: %{"info" => "Saved!"})
{:noreply, patch_elements(socket, "#flash", &render_flash/1)}
end
# After
def save(conn, _params) do
# ... save logic ...
html = ~s(<div id="flash" class="alert alert-info">Saved!</div>)
conn
|> Dstar.start()
|> Dstar.patch_elements(html, selector: "#flash")
endConditional rendering
# Before — use assigns to control render
def handle_event("toggle", _payload, socket) do
{:noreply, update(socket, :show, &(!&1))}
end
# After — use signals (client-side)
# No server round-trip needed! Use Datastar's data-show:
# <div data-show="$show">...</div>
# <button data-on:click="$show = !$show">Toggle</button>
#
# Or if server logic is needed:
def toggle(conn, _params) do
signals = Dstar.read_signals(conn)
conn
|> Dstar.start()
|> Dstar.patch_signals(%{show: !signals["show"]})
endLoading states
# Before — signals on socket
def handle_event("search", %{"query" => q}, socket) do
socket = put_signal(socket, :loading, true)
results = MyApp.Search.run(q)
{:noreply,
socket
|> put_signal(:loading, false)
|> put_signal(:results, results)}
end
# After — signals on conn
def search(conn, _params) do
signals = Dstar.read_signals(conn)
results = MyApp.Search.run(signals["query"])
conn
|> Dstar.start()
|> Dstar.patch_signals(%{loading: false, results: results})
end<%!-- In both cases, the template stays the same --%>
<div data-show="$loading">Searching...</div>Tip: For instant feedback, use Datastar's
data-indicatorattribute to show a loading state client-side before the server responds, without any server-side signal patching.
FAQ
Do I need to migrate all at once?
No. You can run both libraries side-by-side during migration since they use different routes and modules. Remove phoenix_datastar from your deps only after all views are converted.
What about session state across requests?
PhoenixDatastar kept state in a GenServer between requests. Dstar is stateless — each request starts fresh. Move persistent state to:
- The client (Datastar signals — they're sent with every request)
- The database (for data that must survive across sessions)
- ETS / Agent (for in-memory shared state)
- Plug session (for user-specific server state)
What about terminate/1?
If you used terminate/1 for cleanup in live views, that logic should move to wherever you manage the resource lifecycle (e.g., a supervised process, or database cleanup on session expiry).
Can I still use HEEx templates?
Yes. Dstar works with standard Phoenix templates. The only difference is you render them in the controller and pass the HTML string to Dstar.patch_elements/3 instead of returning them from a render/1 callback.
What replaces the mount template / DefaultHTML?
Nothing. Your standard Phoenix layout (root.html.heex, app.html.heex) serves this purpose. Initialize signals with data-signals attributes directly in your templates.
data-signals attributes directly in your templates.