Stateless SSE helpers for Datastar in any Plug/Phoenix app.
Datastar provides a set of composable, stateless functions that write
Server-Sent Events (SSE)
to a chunked Plug.Conn response. The Datastar JavaScript client library
running in the browser receives these events and applies DOM patches, signal
updates, script executions, and redirects — all without a full-page reload.
Compatibility
This library is built for Datastar RC.8+. If you're using an earlier version, some functions or options may not work as expected.
Installation
Add datastar_plug to your mix.exs dependencies:
def deps do
[
{:datastar_plug, "~> 0.2.0"}
]
endQuick Start
Phoenix controller
defmodule MyAppWeb.ItemController do
use MyAppWeb, :controller
alias Datastar
alias MyApp.Items
# GET /items/:id/edit — triggered by a Datastar `data-init` attribute
def edit(conn, %{"id" => id} = params) do
signals = Datastar.parse_signals(params)
item = Items.get!(id)
form_html = render_to_string(conn, :edit_form, item: item)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(form_html, selector: "#item-form")
|> Datastar.patch_signals(%{editMode: true, itemId: id})
|> Datastar.close_sse()
end
# PUT /items/:id — save changes and update the display
def update(conn, %{"id" => id} = params) do
signals = Datastar.parse_signals(params)
item_attrs = Map.take(signals, ["title", "description"])
{:ok, item} = Items.update(id, item_attrs)
display_html = render_to_string(conn, :display, item: item)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(display_html, selector: "#item-display")
|> Datastar.patch_signals(%{editMode: false})
|> Datastar.close_sse()
end
endPlain Plug.Router
defmodule MyApp.Router do
use Plug.Router
plug :match
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug :dispatch
get "/updates" do
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(~s(<div id="status">OK</div>))
|> Datastar.close_sse()
end
endLong-running SSE streams with connection checking
def stream(conn, _params) do
conn = Datastar.init_sse(conn)
do_stream(conn, items())
end
defp do_stream(conn, []), do: conn
defp do_stream(conn, [item | rest]) do
case Datastar.check_connection(conn) do
{:ok, conn} ->
conn
|> Datastar.patch_fragment(render_item(item))
|> do_stream(rest)
{:error, _conn} ->
# Client disconnected — stop streaming silently
conn
end
endSSE Event Protocol
Each SSE event emitted by this library follows the format required by the Datastar SSE specification:
event: <event-type>\n
[id: <event-id>\n]
[retry: <ms>\n]
data: <key> <value>\n
...more data lines...\n
\nThe blank line (double newline \n\n) terminates the event. Multi-line
values (e.g. multi-line HTML) are split into multiple data: lines, one
per original line.
Security Considerations
See the Datastar security reference for the full specification. Key points for this library:
patch_fragment/3— HTML is written verbatim to the SSE stream. If any part of the HTML originates from user input, the caller must sanitise it before passing it to this function to prevent XSS.patch_signals/3— Signal values are JSON-encoded viaJason, so they are safe against injection in the SSE stream itself. However, if signal values are later rendered into HTML on the server, standard output-encoding rules apply.execute_script/3— Executes arbitrary JavaScript on the client. Only pass server-controlled strings. Never interpolate user input directly into the script string.redirect_to/3— The URL is JSON-encoded viaJasonbefore being embedded in thewindow.location.hrefassignment, preventing injection via single-quotes, backslashes, or</script>in the URL.parse_signals/1— Signal data arrives from the browser (user- controlled). Treat all parsed values as untrusted input. Validate and sanitise before using them in queries, rendering, or downstream logic.
Summary
Connection Lifecycle
No-op close marker for SSE pipelines.
Initialises a chunked SSE response on the connection.
Patching the DOM
Executes a JavaScript snippet on the client.
Sends a datastar-patch-elements SSE event to patch HTML into the DOM.
Sends a datastar-patch-elements event with mode: remove to remove a DOM element.
Signals
Sends a datastar-patch-signals SSE event to update client-side signals.
Utilities
Parses the Datastar signal map out of controller params.
Redirects the browser to url via a client-side script event.
Types
Controls how patch_fragment/3 merges incoming HTML into the existing DOM.
The XML namespace in which patch_fragment/3 creates new elements.
Functions
Checks whether the SSE connection is still alive.
Removes one or more signals from the client signal store.
Connection Lifecycle
@spec close_sse(Plug.Conn.t()) :: Plug.Conn.t()
No-op close marker for SSE pipelines.
Including close_sse/1 at the end of a pipeline documents intent: the
response body is complete. In practice, the chunked connection is closed
automatically when the controller action returns and Plug finalises the
response.
Example
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(html)
|> Datastar.close_sse()
@spec init_sse(Plug.Conn.t()) :: Plug.Conn.t()
Initialises a chunked SSE response on the connection.
Sets the required HTTP headers for Server-Sent Events and opens a chunked
200 response. Must be called before any other Datastar.* functions in
the pipeline.
Headers set
| Header | Value |
|---|---|
content-type | text/event-stream — signals SSE to the browser |
cache-control | no-cache, no-store, must-revalidate — prevents caching |
connection | keep-alive — hints to proxies to keep the connection open |
x-accel-buffering | no — disables Nginx / Caddy response buffering |
Example
conn |> Datastar.init_sse()
Patching the DOM
@spec execute_script(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
Executes a JavaScript snippet on the client.
Internally sends a datastar-patch-elements event that appends a <script>
tag to the document <body>. This is the Datastar-recommended pattern for
executing arbitrary scripts from an SSE stream.
Security
Only pass server-controlled strings to this function. Never interpolate
user input directly into script — doing so creates an XSS vulnerability.
Options
:auto_remove— Whentrue, adds adata-effect="el.remove()"attribute to the injected<script>tag. Datastar's reactive system then removes the element from the DOM after it executes, keeping the DOM clean. Defaults tofalse.:event_id— Optional SSE eventidfield.:retry_duration— Optional client reconnect delay in milliseconds.
Examples
conn |> Datastar.execute_script("console.log('hello from Elixir')")
# Auto-remove the script tag after execution
conn |> Datastar.execute_script("doSomething()", auto_remove: true)SSE format emitted
event: datastar-patch-elements
data: selector body
data: mode append
data: elements <script>console.log('hello from Elixir')</script>
# With auto_remove: true
data: elements <script data-effect="el.remove()">doSomething()</script>
@spec patch_fragment(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
Sends a datastar-patch-elements SSE event to patch HTML into the DOM.
Datastar morphs the incoming HTML into the existing DOM using the "outer"
mode by default (id-based element matching + morphing diff). Other merge
modes can be selected via the :merge_mode option.
Multi-line HTML is split into multiple data: elements lines as required
by the SSE protocol.
Security
HTML is written verbatim to the SSE stream. Sanitise any user-supplied content before passing it to this function to prevent XSS.
Options
:selector— CSS selector for the target element (e.g."#my-div"). When omitted, Datastar matches top-level elements byidin"outer"or"replace"mode.:merge_mode— How to apply the patch. Defaults to"outer". Seemerge_mode/0for all allowed values.:namespace— XML namespace for new elements. Defaults to"html". Use"svg"or"mathml"when patching SVG or MathML fragments. Seenamespace/0.:use_view_transition— Whentrue, wraps the DOM patch in the browser's View Transitions API for animated transitions. The browser must support the API; Datastar silently falls back to a plain patch when it does not. Defaults tofalse.:event_id— Optional SSE eventidfield. Allows the client to replay missed events after a reconnect (standard SSELast-Event-IDmechanism).:retry_duration— Optional client reconnect delay in milliseconds (standard SSEretry:field). Only emitted when provided.
Examples
# Default morph — element id must exist in the DOM
conn |> Datastar.patch_fragment(~s(<div id="greeting">Hello!</div>))
# Append a new item to a list
conn |> Datastar.patch_fragment("<li>New item</li>",
selector: "#item-list",
merge_mode: "append"
)
# Patch an SVG fragment
conn |> Datastar.patch_fragment("<circle cx=\"50\" cy=\"50\" r=\"40\"/>",
selector: "#chart",
merge_mode: "inner",
namespace: "svg"
)
# Animated patch with View Transitions
conn |> Datastar.patch_fragment(html, use_view_transition: true)
# With SSE event tracking
conn |> Datastar.patch_fragment(html, event_id: "evt-42", retry_duration: 5000)SSE format emitted
event: datastar-patch-elements
data: selector #greeting
data: mode inner
data: elements <div>Hello!</div>
@spec remove_fragment(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
Sends a datastar-patch-elements event with mode: remove to remove a DOM element.
Removes all elements matching selector from the DOM. No HTML content is
needed — the remove merge mode requires only the selector.
Options
:event_id— Optional SSE eventidfield.:retry_duration— Optional client reconnect delay in milliseconds.
Example
# Remove an item row after it has been deleted on the server
conn |> Datastar.remove_fragment("#item-42")SSE format emitted
event: datastar-patch-elements
data: selector #item-42
data: mode remove
Signals
@spec patch_signals(Plug.Conn.t(), map(), keyword()) :: Plug.Conn.t()
Sends a datastar-patch-signals SSE event to update client-side signals.
The signals map is JSON-encoded and sent to the Datastar client, which
merges the values into its signal store. Existing signals with matching keys
are updated; new keys are added. Setting a signal value to nil removes it
from the client store.
Encoding
Signal values are encoded with Jason.encode!/1. Map keys may be atoms or
strings; atoms are serialised as strings.
Options
:only_if_missing— Whentrue, only signals that do not already exist in the client signal store are patched. Existing signal values are left unchanged. Useful for setting initial/default values. Defaults tofalse.:event_id— Optional SSE eventidfield.:retry_duration— Optional client reconnect delay in milliseconds.
Examples
conn |> Datastar.patch_signals(%{count: 42, loading: false})
# Remove a signal by setting it to nil
conn |> Datastar.patch_signals(%{temp_error: nil})
# Only set signals that the client doesn't already have
conn |> Datastar.patch_signals(%{theme: "dark", locale: "en"}, only_if_missing: true)SSE format emitted
event: datastar-patch-signals
data: signals {"count":42,"loading":false}
# With onlyIfMissing:
event: datastar-patch-signals
data: onlyIfMissing true
data: signals {"theme":"dark"}
Utilities
Parses the Datastar signal map out of controller params.
Datastar encodes signals differently depending on the HTTP method:
GET — All signals are serialised as a JSON string in the
?datastar=query parameter.paramslooks like%{"datastar" => "{"key": "value"}"}. This clause decodes the nested JSON string and returns the resulting map.POST / PUT / PATCH / DELETE — Datastar sends the signal map directly as the JSON request body. The body parser decodes it so
paramsis already the signal map. Route and query parameters (e.g."id") are not filtered out; restrict to known keys withMap.take/2if needed.
Returns %{} when signals cannot be parsed so callers always receive a map.
Security
Signal data originates from the browser and must be treated as untrusted user input. Validate and sanitise all values before using them in queries, HTML rendering, or downstream business logic.
Example
def update(conn, params) do
signals = Datastar.parse_signals(params)
name = Map.get(signals, "newName", "")
conn
|> Datastar.init_sse()
|> Datastar.patch_signals(%{saved: true, name: name})
|> Datastar.close_sse()
end
@spec redirect_to(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
Redirects the browser to url via a client-side script event.
Uses execute_script/3 to send a window.location.href assignment wrapped
in setTimeout(..., 0) so it fires after the current event-loop tick,
giving Datastar time to process any preceding SSE events in the same
response before the navigation occurs.
The URL is JSON-encoded before embedding, preventing injection via
single-quotes, backslashes, or </script> in the URL string.
Options
:event_id— Optional SSE eventidfield.:retry_duration— Optional client reconnect delay in milliseconds.
Examples
conn |> Datastar.redirect_to("/dashboard")
# Works with absolute URLs too
conn |> Datastar.redirect_to("https://example.com/logout")
# With SSE event tracking
conn |> Datastar.redirect_to("/login", event_id: "redirect-1")
Types
@type merge_mode() :: String.t()
Controls how patch_fragment/3 merges incoming HTML into the existing DOM.
These values correspond directly to the mode data line in the Datastar
SSE protocol as implemented in the Datastar JS client (RC.8+).
| Value | Behaviour |
|---|---|
"outer" | Default. Morphs the element in place. Without a :selector, matches top-level elements by id and morphs each one in the DOM. |
"inner" | Replaces the inner HTML of the target element using morphing. |
"replace" | Replaces the target element with replaceWith (no morphing diff). |
"prepend" | Inserts HTML before the first child of the target. |
"append" | Inserts HTML after the last child of the target. |
"before" | Inserts HTML immediately before the target element. |
"after" | Inserts HTML immediately after the target element. |
"remove" | Removes the target element (no HTML content needed). |
@type namespace() :: String.t()
The XML namespace in which patch_fragment/3 creates new elements.
| Value | Description |
|---|---|
"html" | Default. Standard HTML elements. |
"svg" | SVG namespace — use when patching <svg> fragments. |
"mathml" | MathML namespace — use when patching mathematical notation. |
Functions
@spec check_connection(Plug.Conn.t()) :: {:ok, Plug.Conn.t()} | {:error, Plug.Conn.t()}
Checks whether the SSE connection is still alive.
Sends a blank SSE comment line to the client. If the client has
disconnected, the underlying chunk/2 call will return {:error, reason}
and this function returns {:error, conn}.
Unlike the other SSE functions, check_connection/1 returns a tagged
tuple rather than a plain Plug.Conn.t() so that callers can branch on
whether the connection is alive. This makes it useful in long-running SSE
handlers where you want to stop streaming when the client disconnects.
Example
defp stream_items(conn, []), do: conn
defp stream_items(conn, [item | rest]) do
case Datastar.check_connection(conn) do
{:ok, conn} ->
conn
|> Datastar.patch_fragment(render_item(item))
|> stream_items(rest)
{:error, _conn} ->
conn
end
end
@spec remove_signals(Plug.Conn.t(), String.t() | [String.t()], keyword()) :: Plug.Conn.t()
Removes one or more signals from the client signal store.
Accepts a single dot-notated path string or a list of paths. Each path is
converted into a nested map entry with a nil value and sent via
patch_signals/3. Setting a signal to nil removes it from the Datastar
client's signal store (standard JSON Merge Patch / RFC 7396 semantics).
Options
Same as patch_signals/3: :only_if_missing, :event_id, :retry_duration.
Examples
# Remove a single top-level signal
conn |> Datastar.remove_signals("loading")
# Remove a nested signal using dot notation
conn |> Datastar.remove_signals("user.preferences.theme")
# Remove multiple signals in one event
conn |> Datastar.remove_signals(["user.name", "user.email", "cart"])
# Shared-prefix paths are merged correctly
conn |> Datastar.remove_signals(["user.firstName", "user.lastName"])
# Sends: {"user":{"firstName":null,"lastName":null}}SSE format emitted
event: datastar-patch-signals
data: signals {"user":{"name":null,"email":null},"cart":null}