Backpex persists UI state — theme, sidebar, per-resource column visibility,
metric toggles, and anything you want to add — through a pluggable adapter
layer. Out of the box everything lives in the Phoenix session (zero config
required). To route one prefix to a database and keep the rest in the session,
configure that route and an explicit Session :default route; once an
:adapters list exists, Backpex does not add an implicit fallback. Every
setting is routed independently.
How It Works
INITIAL PAGE LOAD
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ Browser ──cookie──▶ Backpex.InitAssigns │
│ │ │
│ ▼ │
│ Backpex.Preferences.get/3 │
│ │ │
│ ▼ │
│ Router (longest-prefix match) │
│ │ │ │
│ global.* resource.* │
│ │ │ │
│ ▼ ▼ │
│ Session adapter Ecto adapter (user-provided) │
│ │ │ │
│ └──────┬───────┘ │
│ ▼ │
│ Server-rendered HTML with resolved state │
│ │
└──────────────────────────────────────────────────────────────────────────┘
USER CHANGES STATE
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ JS toggle or LiveView push_event │
│ │ │
│ ▼ │
│ BackpexPreferences.set(key, value) │
│ │ │
│ ▼ │
│ Ordered queue → coalesced namespace batch │
│ │ │
│ ▼ │
│ POST /backpex_preferences (async, one request per tab at a time) │
│ │ │
│ ▼ │
│ Backpex.PreferencesController → Preferences.put_batch/2 │
│ │ │
│ ▼ │
│ Router → adapter(s) → side effects │
│ │ │
│ ▼ │
│ Best-effort apply: {"ok":true} or {"ok":false,"error":{...}} │
│ │
└──────────────────────────────────────────────────────────────────────────┘Key benefits:
- Server-rendered state. The server renders stored state from the adapter on every request. A bounded pending cookie covers most reloads that race an in-flight write; see its size and scope limitations below.
- Instant UI. Writes are async — the browser never blocks on persistence. Within the current tab and document, the client coalesces same-tick writes and sends only one batch at a time, so an older response cannot overwrite a newer intent or a sibling Session preference.
- Storage is your call. Per-browser session is the default; swap any prefix onto a per-user database with a few lines of config.
What Backpex stores in the browser
| Store | Name | Lifetime | Holds |
|---|---|---|---|
| Your app's session cookie | e.g. _my_app_key | your session config | Where the Session adapter persists preferences. |
sessionStorage | backpex.prefs.<route token>.<key> | the tab | Per-tab mirror of preferences written since the websocket connected. Opt-in per key and isolated by its adapter namespace. |
| Cookie | backpex_prefs | max-age 300, normally deleted within one round-trip | Preference writes the server has not acknowledged yet, each stamped with its adapter namespace token. |
backpex_prefs is written by JavaScript — synchronously, which is the whole
point — so it is not HttpOnly. Attributes: path=/, SameSite=Lax,
max-age=300, plus Secure over HTTPS. The value is a versioned envelope,
{"version": 1, "values": {key: {"token": "...", "value": value}}}, holding
the writes whose POST has not come back yet. Each entry is deleted when its
write is acknowledged. For a rejected batch, only the failed entry is
acknowledged immediately; unaffected entries stay pending and are retried.
Backpex reads the cookie on the disconnected mount only — it is an input to a
render, never to an adapter write. Unlike the sessionStorage mirror it is
shared across tabs of the same browser. See Why the client sometimes overrides
the server.
The encoded cookie has a 3072-byte budget. If several pending writes exceed it, Backpex evicts the oldest entries until it fits. If one entry cannot fit, it is not written to the cookie. No pending cookie is written when the page lacks a usable signed preferences manifest. Persistence still proceeds in both cases, but a document reload inside the POST round trip may initially render the previous stored value.
If your app shows a cookie-consent banner, backpex_prefs is designed as a
strictly functional cookie: it carries keyed namespace tokens rather than raw
user or tenant ids and exists only to render the state the user just requested. Confirm the
classification required by your own jurisdiction and consent policy.
Scoping browser-carried values per adapter
The cookie can outlive the person who wrote it: a preference toggled a moment before "Log out" leaves a POST in flight whose promise dies with the page, so nothing ever retires the entry and it sits there for up to five minutes. If the next user logs in inside that window, an unscoped cookie would be overlaid onto their first paint and replayed into their store.
One application can route global.* into the Phoenix session and
resource.* into a tenant-scoped database. A single fingerprint of the whole
application scope is therefore too broad for the Session adapter and too narrow
for the database adapter. Backpex instead signs one client namespace token per
adapter route. Each token is a keyed digest over the endpoint's
secret_key_base, the adapter module, the namespace reported by that adapter,
and the Phoenix session's CSRF token.
The built-in adapters report the namespace they actually use:
Backpex.Preferences.Adapters.Sessionreports the Phoenix preferences session key and deliberately ignores the application scope. Its token stays stable when the current tenant changes, so theme and sidebar state survive tenant navigation.Backpex.Preferences.Adapters.Ectoreports its repo, schema, and exactly the values named byscope_fields. Its token changes only when a field that can select another database row changes.
Custom adapters may implement the optional
Backpex.Preferences.Adapter.client_namespace/2 callback and return any stable
term that uniquely identifies their storage namespace. Adapters that omit the
callback conservatively use the complete resolved application scope plus their
route options.
Backpex.InitAssigns renders the signed route list into
data-preferences-manifest (pass
preferences_manifest={@preferences_manifest} to app_shell). The browser
routes each key through that manifest and discards only entries whose own token
no longer matches; compatible siblings remain. The preferences endpoint path is
transport metadata, not part of the namespace, so changing a dynamic tenant URL
does not invalidate a compatible Session-backed mirror.
The disconnected mount repeats token validation and does not trust the browser to discard an outlived or planted cookie. The digest is keyed, so neither the manifest nor browser storage exposes raw session, user, or tenant identifiers.
When tokens cannot be signed — no secret_key_base, a layout that does not pass
the manifest, or the very first request of a brand-new session, whose CSRF token
Plug.CSRFProtection only writes back at the end of the response — Backpex
behaves as though the browser overlay did not exist: a first paint that may be
one write stale, never a value attributed to the wrong namespace.
Because the cookie is browser-written and unsigned, anything any script on the
origin can plant reaches a render. Backpex.Preferences.Context.put_client/2
therefore filters both client carriers — the cookie and the connect params —
before they become an overlay (the namespace token is an additional gate, not
a replacement: it says which store owns a value, not that the value is sane):
- keys that fail
Backpex.Preferences.Key.validate/1are dropped; - values that fail
Backpex.Preferences.Keys.valid_value?/2are dropped.
Neither is an authorization gate — a client can already POST any value it likes
to the preferences endpoint. They exist because a render must not raise on
browser input: inert={not @sidebar_open} raises on a string, so without the
value gate a single planted cookie would 500 every admin page until it expired.
A rejected entry simply falls back to the stored value.
Backpex can only shape-check the keys it owns. If you read your own keys out
of the overlay, treat their values as untrusted input and render defensively —
see Backpex.Preferences.Keys.valid_value?/2.
Contracts
Backpex dispatches every preference read and write through a
%Backpex.Preferences.Context{}, but the available session and assigns depend
on how that context was built:
| Call path | ctx.session | ctx.assigns | Client overlay |
|---|---|---|---|
Backpex.Preferences.LiveView.mount_context(socket, session) | current mount session | socket.assigns | cookie or connect params |
Backpex.Preferences.Context.from_conn(conn) / Preferences.put(conn, ...) | current conn session | conn.assigns | none |
bare session passed to get/3 or get_map/3 | supplied session | %{} | none |
Preferences.put(socket, ...) | %{} | socket.assigns | none |
explicitly constructed %Context{} | whatever the caller supplied | whatever the caller supplied | whatever the caller supplied |
Adapters — and the scope resolver they share — should prefer
ctx.assigns, then fall back to ctx.session only on call paths that actually
carry a session. In particular, a server-originated LiveView write must resolve
scope from socket.assigns; Preferences.put(socket, ...) does not have the
mount session.
For that guarantee to hold, the host app must satisfy a handful of ordering and content contracts. None of these are enforced at compile time, so it is worth spelling them out explicitly.
Ordering: authentication and application scope run first
LiveView read path.
Backpex.InitAssignsmust be attached after every hook that builds the application scope so thatsocket.assignsalready holds the user, tenant, workspace, or other namespace fields by the time preferences are read. In a typical Phoenix 1.8live_session:live_session :authenticated, on_mount: [ {MyAppWeb.UserAuth, :ensure_authenticated}, {MyAppWeb.TenantAuth, :assign_tenant}, Backpex.InitAssigns ] do # ... Backpex routes ... endIf the order is reversed,
InitAssignswill see an emptysocket.assignsand the resolver cannot construct the complete scope. Do not silently fall back to a user-only scope when a tenant field is required: that would merge preferences across tenants.Controller write path. Mount the preferences controller behind a pipeline that constructs the same application scope. For a tenant-scoped store, put the endpoint below the tenant segment and authorize the tenant before the controller:
scope "/tenants/:tenant", MyAppWeb do pipe_through [:browser, :require_authenticated_user, :assign_tenant] backpex_routes() endThe generated route contains a dynamic segment, so pass its concrete path to the layout:
<Backpex.HTML.Layout.app_shell socket={@socket} preferences_manifest={@preferences_manifest} preferences_path={~p"/tenants/#{@current_scope.tenant.id}/backpex_preferences"} > ... </Backpex.HTML.Layout.app_shell>
The scope resolver receives a Context
Your resolver gets a %Backpex.Preferences.Context{}, not a raw session.
Read from ctx.assigns — it is the post-auth, post-tenant view. A raw session
usually cannot reconstruct a complete tenant scope. Return :unscoped rather
than returning a partial map that could join namespaces accidentally:
defmodule MyAppWeb.PreferencesScope do
alias Backpex.Preferences.Context
def resolve(%Context{
assigns: %{
current_scope: %{user: %{id: user_id}, tenant: %{id: tenant_id}}
}
}) do
%{user_id: user_id, tenant_id: tenant_id}
end
def resolve(_ctx), do: :unscoped
endThe resolver runs once for each unresolved context. Reusing the resolved
context — as Backpex.InitAssigns does for all reads during one mount — reuses
the same scope. Calls that start from a bare session, conn, or socket build a
fresh context and resolve again. Keep the resolver cheap.
Session key must survive renew_session
The renew_session helper that phx.gen.auth generates (commonly called
on login/logout to rotate the session id) clears the session and re-puts
only an allowlist of keys. Every key outside that allowlist is dropped.
Backpex stores its session-backed preferences under
Backpex.Preferences.session_key/0 (currently "backpex_preferences") — if
you call renew_session in your auth flow, carry that key across, or users
lose their theme, sidebar state, and persisted filters/order/columns every
time they sign in or out:
def renew_session(conn) do
prefs = Plug.Conn.get_session(conn, Backpex.Preferences.session_key())
conn
|> configure_session(renew: true)
|> clear_session()
|> then(fn c ->
if prefs, do: put_session(c, Backpex.Preferences.session_key(), prefs), else: c
end)
endDB-backed adapters are unaffected by renew_session — they key off the
resolved scope map, not the session. This note only matters for prefixes routed to
Backpex.Preferences.Adapters.Session.
Built-in preference keys
Every key Backpex reads or writes is listed here. Third-party code should
prefix its own keys with custom. to avoid colliding with Backpex.
| Key | Type | Read at | Written at | Opt-in? |
|---|---|---|---|---|
global.theme | string | Backpex.InitAssigns | JS theme selector | always on |
global.sidebar_open | boolean | Backpex.InitAssigns | JS desktop sidebar toggle | always on |
global.sidebar_section.<id> | boolean | Backpex.InitAssigns (via get_map/3) | JS sidebar section toggle | always on |
resource:<Module>:columns | map | Index view mount | toggle_column event | persist: [:columns] |
resource:<Module>:metrics_visible | boolean | Index view mount | toggle_metrics event | persist: [:metrics] |
resource:<Module>:order | map | Index view mount (fallback) | handle_params (on change) | persist: [:order] |
resource:<Module>:filters | map | Index view mount (fallback) | handle_params (on change) | persist: [:filters] |
Keys with embedded module names use : as a separator so module-name dots
(e.g. MyApp.MyLive) don't create extra path segments. See
Backpex.Preferences.Key.
global.sidebar_open stores only the desktop (lg and wider) state. The mobile
drawer always starts closed and mobile open/close actions are not persisted.
Sidebar section ids become the final segment of
global.sidebar_section.<id>. Use unique ids matching [A-Za-z0-9_-]+.
Dots and colons alter preference-key parsing, and quotes or backslashes are
unsafe in the browser hook's attribute selector.
Sections without a descendant marked with data-sidebar-item stay hidden
through CSS, so they cannot flash before the LiveView hooks mount.
sidebar_item/1 adds the marker automatically; custom leaf markup must add it
explicitly. Nested sections become visible as soon as any descendant contains a
marked item.
Reading preferences in your layout
Backpex.InitAssigns already populates the assigns that the built-in layout
needs:
@current_theme # "light", "dark", ...
@sidebar_open # true | false
@sidebar_section_states # %{"blog" => true, "settings" => false}<Backpex.HTML.Layout.app_shell
socket={@socket}
fluid={@fluid?}
live_resource={@live_resource}
sidebar_open={@sidebar_open}
preferences_manifest={@preferences_manifest}
>
<:topbar>
<Backpex.HTML.Layout.theme_selector
current_theme={@current_theme}
themes={[{"Light", "light"}, {"Dark", "dark"}]}
/>
</:topbar>
<:sidebar>
<Backpex.HTML.Layout.sidebar_section
id="blog"
sidebar_section_states={@sidebar_section_states}
>
<:label>Blog</:label>
</Backpex.HTML.Layout.sidebar_section>
</:sidebar>
</Backpex.HTML.Layout.app_shell>Custom layouts must render preferences_root
Every preference write is sent by the BackpexPreferences JS hook, which reads
the endpoint to POST to from a single element on the page:
<div id="backpex-preferences" phx-hook="BackpexPreferencesHook" data-preferences-path=... />Backpex.HTML.Layout.app_shell/1 renders that element for you, so a layout
built on app_shell needs nothing extra. A layout that does not use
app_shell must render it itself, once per page:
<Backpex.HTML.Layout.preferences_root
socket={@socket}
preferences_manifest={@preferences_manifest}
/>Without it, every write — theme, sidebar, sidebar sections, and the
persist: keys — is dropped. The failure is quiet: the UI updates
optimistically, so it looks like it worked until the next reload reverts it,
and the only signal is a console warning.
Storage adapters
An adapter owns the "where" of preference storage. Backpex ships Session and
Ecto adapters (the Ecto adapter uses your application's schema and table) and
lets you plug in others per prefix. Backpex.Preferences routes each call
through the adapter configured for the key's prefix.
Picking an adapter
| If you want… | Use… |
|---|---|
| Zero config, per-browser state, small values (theme, sidebar) | Session (default) |
| Per-user, survives across devices, bulky values (columns, filters) | Backpex.Preferences.Adapters.Ecto |
| Pluggable per setting (e.g. theme in session, columns in DB) | Mix both, route by prefix |
The Session adapter stores everything in a single Phoenix session key. If your session is cookie-backed, the entire encoded session cookie — Backpex preferences plus your application's other session data, signing/encryption overhead, and cookie attributes — must fit the browser/Plug limit of 4096 bytes.
The adapter enforces that budget rather than letting the store raise. On every write it estimates the size of the resulting cookie — the whole session, not just Backpex's subtree, since the budget is shared with your app's own session data — and:
- logs a warning once the estimate passes 75% of the budget, and
- refuses the write with
{:error, :too_large}once it would exceed it, leaving the previously stored value untouched.
A refused write surfaces to the browser as 422 {ok: false, error: %{reason: "too_large"}}, and the JS hook stops carrying it. The alternative would be a
Plug.Conn.CookieOverflowError — an HTTP 500 on this and every later request,
with no way for the user to reach a page to undo it.
The warning is the signal to route heavy prefixes (per-resource column visibility, saved filters, etc.) to a database-backed adapter before writes start being refused. If your session is not cookie-backed (ETS, Redis, a database), the 4KB cap does not apply — lift it:
config :backpex, Backpex.Preferences,
adapters: [
{:default, Backpex.Preferences.Adapters.Session, max_bytes: :infinity}
]The estimate is approximate by design: it is a budget check, not an exact
reproduction of Plug.Session's encoding. It errs toward over-estimating, so
it refuses slightly before the true ceiling rather than slightly after.
Routing by prefix
# config/config.exs
config :backpex, Backpex.Preferences,
adapters: [
{"global.*", Backpex.Preferences.Adapters.Session, []},
{"resource.*", Backpex.Preferences.Adapters.Ecto,
repo: MyApp.Repo,
schema: MyApp.Preferences.Preference,
scope_fields: [:user_id, :tenant_id]},
{:default, Backpex.Preferences.Adapters.Session, []}
],
scope: {MyAppWeb.PreferencesScope, :resolve, []}Dispatch uses longest-prefix match, so specific patterns always beat
broader ones and :default regardless of the order they appear in config.
Patterns:
"global.*"— a wildcard: every key under theglobalprefix."global.theme"— an exact key; beats"global.*".:default— fallback when nothing else matches.
With no :adapters config, the router falls back to a single :default →
Session route so existing apps need no changes.
Once you configure :adapters, that zero-config fallback is disabled. Every
key must match a configured exact/wildcard route or an explicit :default.
Otherwise Backpex.Preferences.Router raises when it first resolves the
unmatched key. Include
{:default, Backpex.Preferences.Adapters.Session, []} when unspecified keys
should remain session-backed.
Routing a single resource
Patterns are split into segments by the same rule as keys, so a wildcard can
address one resource by name. Per-resource keys embed the module as a single
:-separated segment — Backpex.Preferences.Keys.columns(MyApp.PostLive) is
"resource:MyApp.PostLive:columns" — and a pattern written the same way carves
that resource out of the broader "resource.*" route:
config :backpex, Backpex.Preferences,
adapters: [
# PostLive carries far more column state than the session cookie should hold.
{"resource:MyApp.PostLive:*", Backpex.Preferences.Adapters.Ecto,
repo: MyApp.Repo,
schema: MyApp.Preferences.Preference,
scope_fields: [:user_id, :tenant_id]},
{"resource.*", Backpex.Preferences.Adapters.Session, []},
{:default, Backpex.Preferences.Adapters.Session, []}
]The narrower route owns every MyApp.PostLive key; every other resource still
goes to the session. Order does not matter — specificity decides.
"*" is only valid as the final segment. A bare "*", or a pattern like
"resource.*.columns", can never match a key, so Backpex raises when the
adapter config is first normalized (normally on the first preference route
resolution) rather than letting the keys you meant to route quietly land
somewhere else. Use :default to match every key.
There is no suffix or predicate matching: adapters own exact keys or prefixes
of the key space. A subtree read (get_map/3) reads every intersecting route
and applies the same specificity rules, so an exact key or nested wildcard can
live in another adapter without becoming invisible to its parent subtree.
Scope resolver
Database adapters need a namespace. Configure one resolver that returns a non-empty atom-keyed map and every adapter receives it:
# config/config.exs
config :backpex, Backpex.Preferences,
scope: {MyAppWeb.PreferencesScope, :resolve, []}defmodule MyAppWeb.PreferencesScope do
alias Backpex.Preferences.Context
def resolve(%Context{
assigns: %{
current_scope: %{user: %{id: user_id}, tenant: %{id: tenant_id}}
}
}) do
%{user_id: user_id, tenant_id: tenant_id}
end
def resolve(_ctx), do: :unscoped
endSee the Contracts section for why the assigns-first order matters and what the host app must guarantee for it to hold.
The dispatcher resolves each context once. Reusing a context with a populated
scope reuses the same map; entry points that receive a bare session, conn, or
socket build a fresh context and resolve again. Keep it cheap (assigns lookup,
session read, or a fast cache hit). Return :unscoped (or raise) when the complete scope
cannot be resolved. Adapter reads are treated as "not found" and the caller falls
back to the :default option; writes return {:error, :unscoped} and
the controller responds with a singular error object:
200 {"ok": false, "error": {"key": "...", "reason": "unscoped"}}
for a single write. In a batch, :unscoped is an ordinary first error and
returns the same body with status 422.
Writing a custom adapter
Implement Backpex.Preferences.Adapter. Three callbacks are required:
get/3— read one key. Return{:ok, value}or{:ok, :not_found}.get_map/3— read everything under a prefix as a nested map.put/4— persist one value. Return{:ok, :persisted}when you stored it yourself (the usual case for a DB adapter), or{:ok, {:put_session, key, map}}to ask the caller to writemapinto the Phoenix session.
One callback is optional but recommended when the adapter does not use the complete application scope:
client_namespace/2— return{:ok, stable_term}describing exactly the namespace in which this adapter stores the current route's values. Backpex signs the term; it is never sent to the browser in the clear. Return{:error, :unscoped}when no safe namespace is available. If the callback is omitted, Backpex uses the complete resolved scope and the route options.
For example, an adapter keyed only by account should project away an unrelated workspace switch:
@impl true
def client_namespace(%Context{scope: %{account_id: account_id}}, _opts) do
{:ok, {:account, account_id}}
end
def client_namespace(_ctx, _opts), do: {:error, :unscoped}The term must change whenever the adapter could read a different value for the same preference key, and remain stable when only transport details (such as a dynamic endpoint path) change.
The side-effect protocol keeps adapters independent of Plug.Conn. A Session
adapter describes the session effect for the controller to apply; a database
adapter may perform its own storage write and return {:ok, :persisted}. This
lets the controller compose cross-adapter batch writes and lets server-side
code dispatch the same adapters without an HTTP request.
{:put_session, _, _} is only honorable on a %Plug.Conn{} —
Plug.Session is HTTP-only. An adapter that stores in the session must
return {:error, :requires_http} when called outside a controller (the
Session adapter does exactly this), so the dispatcher can round-trip the
write through the browser instead.
Browser writes are coalesced by signed adapter namespace and dispatched through
a per-document single-flight queue. This ordering is important for the Session
adapter: the next request in that tab is created only after the previous
response has installed its updated session cookie. Writes to the same key that
have not started yet are coalesced to the latest value. Request bodies are split
below the browser's keepalive size limit; an individually oversized body is
sent without keepalive.
Batch writes are best-effort, first-error-wins: on the first adapter
error the dispatcher halts, returns {:error, {key, reason}}, and the
controller responds 422 {"ok": false, "error": {"key": "...", "reason": "..."}} without
applying any session-backed side effects collected earlier in the batch.
Adapters that persist eagerly (e.g. a DB-backed adapter that wrote via
Repo.insert!) may have already committed earlier writes — the adapter
behaviour has no rollback primitive, so callers should treat partial
success as possible.
HTTP endpoint contract
backpex_routes/0 mounts POST /backpex_preferences (under the surrounding
scope). BackpexPreferences.set(...) is the normal client and sends ordered,
coalesced batches with the Phoenix CSRF header. Custom clients may send either
supported payload shape:
{"key": "custom.dashboard.view_mode", "value": "list"}{"preferences": [
{"key": "global.theme", "value": "dark"},
{"key": "global.sidebar_open", "value": false}
]}The response contract is:
200 {"ok": true}when all retained entries were accepted.200 {"ok": false, "error": {"key": "...", "reason": "unscoped"}}when a single write targets an adapter that cannot resolve its complete scope.422 {"ok": false, "error": {"key": "...", "reason": "..."}}for the first adapter error in a batch or any other write error.400 {"ok": false, "error": "missing key/value"}when the outer payload matches neither supported shape.
The batch parser silently discards members that are not objects containing a
binary key and a value field. An empty batch, or a batch where every member
is discarded, therefore returns 200 {"ok": true} and performs no writes.
The dispatcher validates value shapes for keys Backpex owns, regardless of
whether the write came from the controller, a LiveView, or host application
code. A wrong-typed built-in value returns :invalid_value before any adapter
sees it. Unknown and custom.* keys remain adapter-owned and may carry any
value their adapter accepts; this shape check is not an authorization boundary.
Adapter reads are necessarily defensive too, because rows may predate the
validation or be written outside Backpex.
Respond only once the value is readable
The browser retires its client overlay for a key as soon as that write is
acknowledged (see Why the client sometimes overrides the
server). A successful batch
acknowledges every entry. A 422 acknowledges its failed entry but requeues the
other entries, because the controller may not have applied them. An adapter that
persists asynchronously — a queued or fire-and-forget write, a database with
read-replica lag — breaks that rule: the overlay retires while the next read
still returns the old value, and the flicker the subsystem exists to remove
comes back. put/4 must not return until the value is readable by a subsequent
get/3.
In-memory test adapter
Useful when exercising preferences in integration tests without spinning up a database:
defmodule MyApp.Test.InMemoryPreferencesAdapter do
@behaviour Backpex.Preferences.Adapter
@table :my_app_test_prefs
def start do
case :ets.whereis(@table) do
:undefined -> :ets.new(@table, [:named_table, :public, :set])
_ref -> :ok
end
end
def reset, do: (start(); :ets.delete_all_objects(@table); :ok)
@impl true
def get(ctx, key, _opts) do
start()
case :ets.lookup(@table, {scope(ctx), key}) do
[{_, value}] -> {:ok, value}
[] -> {:ok, :not_found}
end
end
@impl true
def get_map(ctx, prefix, _opts) do
start()
# Reconstruct a nested map from flat (scope, key) rows — see
# lib/backpex/preferences/adapters/session.ex for the shape to return.
{:ok, %{}}
end
@impl true
def put(ctx, key, value, _opts) do
start()
:ets.insert(@table, {{scope(ctx), key}, value})
{:ok, :persisted}
end
defp scope(%{scope: nil}), do: :unscoped
defp scope(%{scope: scope}), do: scope
endBackpex itself uses exactly this pattern for its dispatcher tests — see
test/support/in_memory_preferences_adapter.ex for a fully-worked version.
Database-backed preferences
Backpex.Preferences.Adapters.Ecto stores one row per preference key. Reach
for it when you outgrow the Session adapter's ~4KB cookie budget, or when
preferences should follow a user across devices and browsers.
You supply the table; Backpex supplies the adapter. There is no adapter module to write.
Setup
defmodule MyApp.Repo.Migrations.CreateBackpexPreferences do
use Ecto.Migration
def change do
create table(:backpex_preferences) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :tenant_id, references(:tenants, on_delete: :delete_all), null: false
add :key, :string, null: false
add :value, :map, null: false, default: %{}
timestamps(type: :utc_datetime_usec)
end
create unique_index(:backpex_preferences, [:user_id, :tenant_id, :key])
end
end
defmodule MyApp.Preferences.Preference do
use Ecto.Schema
schema "backpex_preferences" do
field :user_id, :integer
field :tenant_id, :integer
field :key, :string
field :value, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
end# config/config.exs
config :backpex, Backpex.Preferences,
adapters: [
{:default, Backpex.Preferences.Adapters.Ecto,
repo: MyApp.Repo,
schema: MyApp.Preferences.Preference,
scope_fields: [:user_id, :tenant_id],
storage_key_prefix: "backpex."}
],
scope: {MyAppWeb.PreferencesScope, :resolve, []}The unique index is required and must contain scope_fields ++ [:key] in the
same order because writes use that list as their conflict target. Every scope
field must exist on the schema, and its type must match the corresponding value
returned by the scope resolver. The :key and :value field names are fixed.
storage_key_prefix is optional and defaults to "". When configured, the Ecto
adapter prepends it only in the database and removes it again on reads, so callers
continue using Backpex's logical keys. The value is used exactly as configured;
include the separator you want, for example "backpex." stores
global.theme as backpex.global.theme. Changing the prefix selects a new
storage namespace; existing rows under another prefix are not read or migrated.
Match your app's primary key convention
The schema above uses Ecto's default :id primary key. If your app sets
migration_primary_key: [type: :binary_id] on the repo — or generates
schemas from a base module that does — the migration creates a uuid id
column with no database default, while a bare use Ecto.Schema still expects
the database to fill it. Inserts then fail at runtime with null value in column "id" violates not-null constraint. Declare the primary key the way
the rest of your schemas do:
@primary_key {:id, :binary_id, autogenerate: true}The example routes every key through Ecto so theme, sidebar, filters, order and columns are all tenant-scoped. Route selected prefixes to the Session adapter only when those settings should deliberately be session-scoped instead.
How values are stored
Preference values are frequently scalars — metrics_visible and every
global.sidebar_section.<id> are booleans, global.theme is a string — and a
:map column cannot hold those. Every value is therefore wrapped in a
%{"value" => term} envelope on write and unwrapped on read:
key | value
------------------------------------------------+----------------------------------
backpex.global.sidebar_section.blog | {"value": true}
backpex.resource:MyApp.PostLive:order | {"value": {"by": "id", ...}}The backpex. prefix in this example comes from storage_key_prefix; without
that option the logical keys are stored unchanged. Worth knowing when reading
the table by hand or asserting on rows in a test.
Design variant — prefix → column mapping
Use when you already have a user settings table (one row per user) with typed
JSON columns, and want each Backpex prefix to write into a named column rather
than a generic rows table. That needs a custom adapter (see
Writing a custom adapter); dispatch reads and
writes on the key's segments and build get_map/3's return value with
Backpex.Preferences.Adapter.nest/2. The
ash_backpex community project is a
worked example.
Opt-in persistence for ordering, filters, columns, metrics
By default Backpex.LiveResource keeps ordering and filters in the URL and
column and metric visibility in-memory. Opt in per resource to persist any
subset via Backpex.Preferences:
use Backpex.LiveResource,
adapter_config: [...],
persist: [:order, :filters, :columns, :metrics]What each flag does:
:order— readsresource:<Module>:orderat mount; uses it as the initial order when the URL has noorder_by/order_directionparams. Writes every time the user changes the order. A stored order takes precedence overinit_orderon later mounts, so nothing is written until the user actually picks one: passively viewing an index leaves the resource free to keep deciding its own default (see below).:filters— readsresource:<Module>:filtersat mount; uses it as the fallback filter set when the URL has nofiltersparam. Writes every time filters change.:columns— readsresource:<Module>:columnsat mount; writes ontoggle_columnevents. Default without opt-in is to keep column state in-memory only.:metrics— readsresource:<Module>:metrics_visibleat mount; writes ontoggle_metricsevents. Default without opt-in is to show metrics on every mount and keep the toggle in-memory only.
All four keys route through whichever adapter you configured for
"resource.*" — typically the Session adapter by default, or a per-user DB
adapter once you wire one up.
:order and init_order
A stored order overrides init_order from the next mount on. That is the
point of the option — but it means an order that gets stored is an order the
resource can no longer choose for that user. So a preference is written only
when the user picks an order (a column-header click, or order_by /
order_direction in the URL), never on a plain index view.
That keeps the two composable: init_order decides the default for as long as
the user has expressed no preference, including a fun/1 that recomputes it
per render, and a user who has picked an order keeps it. Were the default
written on first view instead, it would freeze per user at whatever it
resolved to the first time they happened to open the page — and a later change
to the resource's default would never reach them.
Replacing a hand-rolled persistence layer
If your app already persists ordering, filters, or column state through a
custom init_order callback backed by a DB table, the persist: option
replaces that scaffolding:
# Hand-rolled
use Backpex.LiveResource, adapter_config: [...]
def init_order(assigns), do: MyApp.OrderingSettings.fetch(assigns.current_user, __MODULE__)
def handle_event(...) do
# ... hand-rolled write to MyApp.OrderingSettings ...
end# With persist:
use Backpex.LiveResource,
adapter_config: [...],
persist: [:order]
# MyApp.OrderingSettings writes move into the configured preference adapter;
# every opt-in resource benefits.Custom preferences
The system is a flat key-value store with a namespace convention. Use
custom.* for your own keys — the router won't collide with anything Backpex
ships.
Reading (server-side)
def mount(_params, session, socket) do
ctx = Backpex.Preferences.LiveView.mount_context(socket, session)
view_mode = Backpex.Preferences.get(ctx, "custom.dashboard.view_mode", default: "grid")
panel_states = Backpex.Preferences.get_map(ctx, "custom.dashboard.panels")
{:ok, assign(socket, view_mode: view_mode, panel_states: panel_states)}
endUse mount_context/2 during mount/3, after your authentication on-mount
hook. It preserves socket.assigns for scope resolution and includes the
validated pending-cookie/connect-param overlay. Passing the bare session
map is supported, but it has no assigns and cannot see pending or per-tab
mirrored values.
Writing from the browser
import { BackpexPreferences } from 'backpex'
BackpexPreferences.set(
'custom.dashboard.view_mode',
'list',
{ mirror: 'session' }
)Writing from the server
From a LiveView handle_event, use Backpex.Preferences.put/4:
def handle_event("toggle_view_mode", _params, socket) do
new_mode = if socket.assigns.view_mode == "grid", do: "list", else: "grid"
{:ok, socket} =
Backpex.Preferences.put(
socket,
"custom.dashboard.view_mode",
new_mode,
mirror: :session
)
{:noreply, assign(socket, :view_mode, new_mode)}
endUnder the hood put/4 tries the configured adapter first. When the
adapter is session-backed (no HTTP request in a LiveView event), it falls
back to a push_event/3 round-trip so the browser persists via the
preferences controller on its next paint. DB-backed adapters just write
directly and return. mirror: :session matters on the Session fallback because
this preference is read at mount and must survive a same-socket live
navigation; it is ignored when the configured adapter persists server-side.
Namespace and subtree limitations
Browser overlays accept only the top-level prefixes recognized by
Backpex.Preferences.Key.validate/1: global, resource, and custom. There
is no API for registering another top-level prefix. Put application-owned keys
under custom.* if they must participate in the pending cookie or LiveView
connect-param overlay.
get_map/3 overlays client values by looking for dot-form descendants of
prefix (prefix <> "."). Use dot-separated custom.* keys for application
subtrees such as custom.dashboard.panels.left. A colon-form subtree such as
resource:MyApp.PostLive:* can still be read from adapters, but pending client
entries are not reconstructed into get_map/3; read those keys individually
with get/3 when client-overlay precedence is required.
Gotchas
Default vs. explicit empty
Don't treat a resolved %{} or [] as "never set" — a user who explicitly
cleared their filters (or their columns, or any other map/list preference)
stored that empty value deliberately. Overwriting it with a default on the
next mount means every page load fights the user's choice.
Read with Backpex.Preferences.get/3 and no :default. A missing value
then comes back as nil, so is_nil/1 separates the two cases:
# Wrong — treats "user cleared filters" the same as "no preference".
filters = Backpex.Preferences.get(session, key, default: %{})
if filters == %{}, do: apply_defaults(), else: use(filters)
# Right — distinguishes the two.
case Backpex.Preferences.get(session, key) do
nil -> apply_defaults() # user has never set this
filters -> use(filters) # includes an explicit %{}
endThis is exactly what Backpex.LiveResource's own persist: [:filters]
wiring does: it reads the persisted filters with a bare get/3 and skips
the default-filter redirect whenever the result is not nil. Apply the same
pattern in any custom persistence logic you build on top of
Backpex.Preferences.
The default Session adapter reserves nil for “not found”: storing nil and
then reading it is indistinguishable from a missing key, even if you pass a
sentinel default. Store a tagged value such as %{"value" => nil} instead. A
custom adapter may preserve nil by returning {:ok, nil}; only with such an
adapter can default: :__unset__ distinguish missing from stored nil.
Testing Backpex LiveResources
When a resource has filter presets (a
:default on a filter config) and the user has no persisted filter state
yet, the Index view issues a push_navigate on first mount to apply those
defaults. Under Phoenix.LiveViewTest.live/2 that surfaces as an
{:error, {:live_redirect, _}} tuple that your test has to match on and
re-mount — every integrator hits this footgun the first time they write an
Index-mount test.
Don't re-mount by hand. Phoenix.LiveViewTest.follow_redirect/2 exists for
exactly this: pass it the {:error, {:live_redirect, _}} tuple and it mounts
the target for you, returning the usual {:ok, view, html}.
test "index renders with the preset filters applied", %{conn: conn} do
result = live(conn, ~p"/admin/posts")
assert {:error, {:live_redirect, %{to: to}}} = result
assert to =~ "filters[published][]=published"
{:ok, _view, html} = follow_redirect(result, conn)
assert html =~ "Published"
endfollow_redirect/2 recycles the conn, carries the LiveView connect params
across the hop and re-signs the flash cookie, so the second mount sees the
session the first one did. When you don't need to assert on the target,
conn |> live(~p"/admin/posts") |> follow_redirect(conn) is the whole dance.
To seed a preference so that the mount reads it as if the user had set it
earlier — pinning a persisted-state branch such as "user explicitly cleared
all filters" — write it onto the conn with Backpex.Preferences.put/3 before
mounting. It dispatches through the configured adapter just like a production
write, so an adapter backed by your database sees the seed too:
alias Backpex.Preferences
alias Backpex.Preferences.Keys
test "persisted empty filters suppress the default-filter redirect", %{conn: conn} do
{:ok, conn} = Preferences.put(conn, Keys.filters(MyAppWeb.PostLive), %{})
# No redirect — the explicit empty filter state beat the `:default`.
{:ok, _view, html} = live(conn, ~p"/admin/posts")
assert html =~ "Draft"
assert html =~ "Published"
endWriting a JS hook that persists preferences
Why the client sometimes overrides the server
The precedence rule, stated once: the client overrides the server exactly
when it holds a write the server has not acknowledged. 200 {"ok": true}
acknowledges every entry in its request, and the single-entry
200 {"ok": false, "error": {"key": "...", "reason": "unscoped"}}
acknowledges that rejected write. On a batch 422, only the named failed key is
acknowledged: the controller stops at that entry, drops accumulated Session
effects, and never dispatches later entries, so every other still-current entry
is retried. Network failures and 5xx responses leave entries pending for a
reload replay. Until acknowledgement, the browser is the only party that knows
what the user picked, and it has to carry that knowledge to the server itself.
It does so over two carriers, because a page is rendered over two different transports and each transport can only see one of them:
The backpex_prefs cookie → the document GET (the disconnected "dead"
render). document.cookie is written synchronously inside the click handler,
so the value rides the very next request the browser makes — including a reload
fired milliseconds later, long before the keepalive POST's Set-Cookie has
updated the session. Without it, the GET carries a session cookie that is up to
one POST round-trip stale, the first paint shows the pre-toggle state, and
LiveView patches it away a frame later: the flash. Backpex.Preferences.LiveView
decodes the cookie on the disconnected mount and folds it into the same client
overlay the connect params feed. Every key gets this automatically — mirrored or
not — which is why filters, order and column visibility also survive a fast
reload, not just the sidebar. A pending write only ever applies to the scope
that made it: see Scoping browser-carried values per adapter.
The sessionStorage mirror → the websocket join. LiveView freezes the
Phoenix session at websocket-connect time. When a user clicks an internal link
that does a live_redirect, LiveView re-mounts the target view on the same
socket — so on_mount callbacks (including Backpex.InitAssigns) read that
frozen snapshot and re-render UI chrome from the pre-write value. No HTTP
request happens there, so the cookie plays no part: the mirror is the only
carrier that exists on that path. sessionStorage is the natural fit — same
tab, cleared on tab close, no cookie-size pressure — and BackpexPreferences
provides get(key, fallback) and set(key, value, { mirror: 'session' }) so
every hook gets the same namespaced (backpex.prefs.*) behavior without
reinventing load/save helpers. The mirror reaches the server in the LiveView
connect params, which is why backpexParams must be wired into your
LiveSocket (see the installation guide):
LiveView re-evaluates those params on every join, so mount/3 sees the tab's
post-connect writes before it renders.
Both carriers feed the same server-side client overlay
(Backpex.Preferences.Context), so the dead render and the connected render
derive their state from the same values and agree by construction.
On the first join of a page load the browser reconciles the two. Every
mirrored key the server has acknowledged is dropped: the document GET just
re-read the storage backend, so for those keys its render is authoritative, and
a mirror left over from an earlier page load (or made stale by another tab
writing the shared session cookie) must not override it. Every key the server
has not acknowledged is kept and sent — the dead render already honored those
out of backpex_prefs, and withholding them would make the connected render
contradict the paint the user is looking at.
When to use mirror: 'session'
mirror governs the long-lived per-tab mirror only. The short-lived pending
cookie is attempted for every key regardless, so the mirror choice is primarily
about whether the value must survive a live_redirect. The pending cookie is a
best-effort fast-reload carrier: it is capped at 3072 encoded bytes, evicts old
entries, skips a single oversized entry, and is disabled without a signed
preferences manifest.
Use it when all of the following are true:
- The preference controls state the LiveView re-renders on every mount — UI chrome (sidebar, density, nav variant, table zoom, …), the theme selector's checked radio, or server-rendered content such as column and metric visibility.
- The server reads this preference from the session at mount time (so the
frozen snapshot will bite you on
live_redirect). - The client-side value can diverge from the server's view between a write and the next fresh websocket handshake.
When NOT to use it
Skip the mirror (just call BackpexPreferences.set(key, value)) when:
- The value round-trips through something the server sees on every render
anyway and is not also used as a persisted mount fallback. Transient filters
and order with the default
persist: []live entirely in the URL and do not need preference writes at all. - The server is always authoritative on every render — e.g. a DB-backed
preference read fresh from Ecto in
mount/3. There's no frozen snapshot to override. - You need cross-tab consistency within the browser —
sessionStorageis per-tab; a mirror there will diverge between two tabs of the same admin. (backpex_prefsis shared across tabs, but it only ever holds a write for as long as its POST is in flight, so it cannot pin a divergence.)
The write queue is also per document. Separate tabs, or an old keepalive
request finishing after a full reload, can still update a cookie-backed Session
adapter concurrently because browser session cookies have no atomic compare-and-
set operation. Use a database-backed adapter when preferences require strict
cross-tab or cross-document ordering.
When persist: [:filters] or persist: [:order] is enabled, Backpex does use
mirror: :session for the preference write. The URL remains authoritative when
it contains the relevant params, while the mirrored preference supplies the
mount fallback when it does not. Custom code should follow the same rule when a
URL-backed value is also persisted as a future default.
An unmirrored key still attempts to use the pending cookie for a fast reload.
What it always gives up is the live_redirect guarantee; if the pending cookie
cannot carry the entry, the first paint of a racing reload may also be stale.
Server-originated writes: Preferences.put/4
The mirror also covers preferences the server writes, such as column and metric visibility. These are server-rendered content (a hidden column is not in the DOM at all), so a client hook cannot re-apply them the way the sidebar hooks re-apply CSS state — the server has to know before it renders.
Write them from a LiveView with Backpex.Preferences.put/4:
{:ok, socket} = Backpex.Preferences.put(socket, key, value, mirror: :session)The adapter decides how that write lands, which is why this is the entry point to reach for:
- An adapter that can persist server-side takes the write in place. The browser
is never involved, the next mount reads the value back fresh, and
:mirroris moot — there is no round-trip to mirror. - The Session adapter cannot write outside an HTTP request cycle, so it refuses
with
{:error, :requires_http}and the dispatcher falls back to apush_eventthat theBackpexPreferenceshook POSTs back. That round-trip is what:mirrorgoverns:mirror: :sessiontells the hook to mirror the value into sessionStorage in addition to the POST.- Every subsequent join sends the mirrored values in the connect params.
Backpex.Preferences.LiveView.mount_context/2folds them into theContext, whereget/3andget_map/3prefer them over the stored value — so the first render already reflects the toggle.
Backpex.Preferences.LiveView.push_write/4 emits that push_event directly and
skips the adapter entirely. It is the transport primitive the fallback is built
on; prefer put/4 unless you specifically need the browser round-trip, so that
a host routing your key to a server-side adapter does not pay for one it does
not need.
Example: a compact-density toggle
// assets/js/hooks/compact_density_toggle.js
import { BackpexPreferences } from 'backpex'
const KEY = 'custom.density.compact'
export default {
mounted () {
// Seed from the mirror first (live_redirect-safe), falling back to
// the server-rendered data attribute on fresh connects.
this.serverCompact = this.el.dataset.compact === 'true'
this.compact = BackpexPreferences.get(KEY, this.serverCompact)
this.applyDensity(this.compact)
this.el.addEventListener('click', () => {
this.compact = !this.el.classList.contains('density-compact')
this.applyDensity(this.compact)
// Marks the write pending in `backpex_prefs` (so a reload right now still
// paints it), mirrors it to sessionStorage (so it survives live_redirect),
// then queues it for an ordered, coalesced preferences POST.
BackpexPreferences.set(KEY, this.compact, { mirror: 'session' })
})
},
updated () {
const serverCompact = this.el.dataset.compact === 'true'
if (serverCompact === this.serverCompact) return
this.serverCompact = serverCompact
// The attribute *changed*, so the server has new information — adopt it,
// unless this tab holds a write the server has not acknowledged yet, in
// which case the render we are looking at predates the user's click.
if (!BackpexPreferences.isPending(KEY)) this.compact = serverCompact
this.applyDensity(this.compact)
},
applyDensity (compact) {
this.el.classList.toggle('density-compact', compact)
this.el.setAttribute('aria-pressed', String(compact))
}
}Two API notes for hook authors:
get/2uses the runtime type of the fallback to deserialize: a boolean fallback returnstrue/false; a number returns a parsed number; a string passes through; anything else (map, array) round-trips through JSON. Pick a fallback whose type matches what youset/3-ed originally and the round-trip stays transparent.isPending(key)is the gate any hook must check before adopting a server-rendered attribute. Re-asserting a cached client value on everyupdated()leaves the server no path to ever correct the hook; adopting the attribute unconditionally undoes a click the server has not seen yet. Adopt it when it changed and the key is not pending — that is the precedence rule, applied in the DOM. The built-inBackpexSidebarandBackpexSidebarSectionshooks do exactly this.
Troubleshooting
"My preferences vanish after a few writes." A cookie-backed session has a
hard size limit (commonly about 4KB for the complete encoded session, not just
Backpex's tree). Plug does not silently truncate an oversized session; the
response write may fail with Plug.Conn.CookieOverflowError. The
Session adapter warns once its own tree passes 3072 bytes, but other session
data and encoding overhead can make failure happen earlier. Route bulky
prefixes (columns, filters) onto a database adapter.
"Changes aren't saving for some scopes." The configured adapter likely
returned {:error, :unscoped} because the resolver could not build the full
scope, or {:error, {:invalid_scope, fields}} because a configured field was
missing. Check conn.assigns / socket.assigns after the authentication and
tenant hooks have run.
"I want to inspect what's stored for a scope." Session adapter: read
Plug.Conn.get_session(conn, "backpex_preferences") directly. DB adapter:
query your table (backpex_user_preferences or whatever you named it).
"How do I reset a scope's preferences?" Drop its rows (DB adapter) or
Plug.Conn.delete_session(conn, "backpex_preferences") (session). No
Backpex-specific API exists — treat the store like the data store it is.