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

StoreNameLifetimeHolds
Your app's session cookiee.g. _my_app_keyyour session configWhere the Session adapter persists preferences.
sessionStoragebackpex.prefs.<route token>.<key>the tabPer-tab mirror of preferences written since the websocket connected. Opt-in per key and isolated by its adapter namespace.
Cookiebackpex_prefsmax-age 300, normally deleted within one round-tripPreference 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.Session reports 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.Ecto reports its repo, schema, and exactly the values named by scope_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):

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 pathctx.sessionctx.assignsClient overlay
Backpex.Preferences.LiveView.mount_context(socket, session)current mount sessionsocket.assignscookie or connect params
Backpex.Preferences.Context.from_conn(conn) / Preferences.put(conn, ...)current conn sessionconn.assignsnone
bare session passed to get/3 or get_map/3supplied session%{}none
Preferences.put(socket, ...)%{}socket.assignsnone
explicitly constructed %Context{}whatever the caller suppliedwhatever the caller suppliedwhatever 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.InitAssigns must be attached after every hook that builds the application scope so that socket.assigns already holds the user, tenant, workspace, or other namespace fields by the time preferences are read. In a typical Phoenix 1.8 live_session:

    live_session :authenticated,
      on_mount: [
        {MyAppWeb.UserAuth, :ensure_authenticated},
        {MyAppWeb.TenantAuth, :assign_tenant},
        Backpex.InitAssigns
      ] do
      # ... Backpex routes ...
    end

    If the order is reversed, InitAssigns will see an empty socket.assigns and 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()
    end

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

The 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)
end

DB-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.

KeyTypeRead atWritten atOpt-in?
global.themestringBackpex.InitAssignsJS theme selectoralways on
global.sidebar_openbooleanBackpex.InitAssignsJS desktop sidebar togglealways on
global.sidebar_section.<id>booleanBackpex.InitAssigns (via get_map/3)JS sidebar section togglealways on
resource:<Module>:columnsmapIndex view mounttoggle_column eventpersist: [:columns]
resource:<Module>:metrics_visiblebooleanIndex view mounttoggle_metrics eventpersist: [:metrics]
resource:<Module>:ordermapIndex view mount (fallback)handle_params (on change)persist: [:order]
resource:<Module>:filtersmapIndex 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 the global prefix.
  • "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
end

See 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 write map into 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
end

Backpex 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 — reads resource:<Module>:order at mount; uses it as the initial order when the URL has no order_by / order_direction params. Writes every time the user changes the order. A stored order takes precedence over init_order on 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 — reads resource:<Module>:filters at mount; uses it as the fallback filter set when the URL has no filters param. Writes every time filters change.
  • :columns — reads resource:<Module>:columns at mount; writes on toggle_column events. Default without opt-in is to keep column state in-memory only.
  • :metrics — reads resource:<Module>:metrics_visible at mount; writes on toggle_metrics events. 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)}
end

Use 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)}
end

Under 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 %{}
end

This 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"
end

follow_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"
end

Writing 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 — sessionStorage is per-tab; a mirror there will diverge between two tabs of the same admin. (backpex_prefs is 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 :mirror is 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 a push_event that the BackpexPreferences hook POSTs back. That round-trip is what :mirror governs:
    1. mirror: :session tells the hook to mirror the value into sessionStorage in addition to the POST.
    2. Every subsequent join sends the mirrored values in the connect params.
    3. Backpex.Preferences.LiveView.mount_context/2 folds them into the Context, where get/3 and get_map/3 prefer 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/2 uses the runtime type of the fallback to deserialize: a boolean fallback returns true/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 you set/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 every updated() 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-in BackpexSidebar and BackpexSidebarSections hooks 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.