Bump Your Deps
Update Backpex to the latest version:
defp deps do
[
{:backpex, "~> 0.20.0"}
]
endv0.20 brings two changes that touch every application: UI state (theme, sidebar,
column and metric visibility) now goes through a unified, server-rendered
preference system instead of cookies and localStorage, and the app_shell
ships a collapsible sidebar with a new slot API.
Work through the steps below in order. After each step, verify in the browser that the feature still works — toggle theme, columns, metrics and sidebar sections, navigate to another resource, then reload.
1. Router: remove ThemeSelectorPlug, mount backpex_routes()
Backpex.ThemeSelectorPlug has been removed. The theme is now assigned by the
Backpex.InitAssigns on-mount hook.
pipeline :browser do
...
- plug Backpex.ThemeSelectorPlug
endbackpex_routes() is now required: app_shell/1 resolves the preferences
endpoint on every render and raises when the route is missing. In v0.19 the
equivalent cookie route was only needed by theme_selector and
toggle_columns, so a layout without either could render without it.
# router.ex
scope "/" do
pipe_through :browser
backpex_routes()
endThe route was renamed from /backpex_cookies to /backpex_preferences, and
Backpex.Router.cookie_path/1 to Backpex.Router.preferences_path/1. If the
routes are mounted more than once, or inside a scope with dynamic segments, pass
an explicit preferences_path to app_shell.
Because Backpex.InitAssigns is a LiveView hook, @current_theme is only
assigned on LiveView-rendered pages. Controller-rendered pages that share your
root layout (a login page, error pages) fall back to the layout's default theme.
If they should follow the stored preference, read it in your own plug — it must
run after fetch_session:
def assign_current_theme(conn, _opts) do
ctx = Backpex.Preferences.Context.from_conn(conn)
theme = Backpex.Preferences.get(ctx, Backpex.Preferences.Keys.theme())
Plug.Conn.assign(conn, :current_theme, if(is_binary(theme), do: theme))
end2. app.js: wrap your connect params with backpexParams
Backpex sends the browser's stored preferences in the LiveView connect params so
mount/3 can render the user's actual state on the first render. Without it,
preferences written after the websocket connected revert as soon as the user
navigates to another resource (Backpex logs a console warning).
- import { Hooks as BackpexHooks } from 'backpex'
+ import { Hooks as BackpexHooks, backpexParams } from 'backpex'
const liveSocket = new LiveSocket('/live', Socket, {
- params: { _csrf_token: csrfToken },
+ params: backpexParams({ _csrf_token: csrfToken }),
hooks: { ...BackpexHooks }
})Keep it a function, not a plain object — LiveView re-evaluates it on every join. If your app already computes connect params dynamically, see Backpex Hooks and connect params.
If you were calling BackpexHooks.BackpexThemeSelector.setStoredTheme(), remove
it. The server now renders the correct theme on initial page load.
If you register hooks individually instead of spreading BackpexHooks, register
all of BackpexSidebar (new, drawer state), BackpexSidebarSections and
BackpexPreferencesHook — omitting the latter silently disables preference
persistence.
3. Root layout: @theme becomes @current_theme
- <html data-theme={assigns[:theme] || "light"}>
+ <html data-theme={assigns[:current_theme] || "light"}>4. Admin layout: app_shell, branding, theme selector, sections
Backpex.InitAssigns provides four assigns for the layout:
| Assign | Type | Description |
|---|---|---|
@current_theme | string | The user's selected theme |
@sidebar_open | boolean | Whether the sidebar is open (desktop) |
@sidebar_section_states | map | Map of section IDs to open/closed state |
@preferences_manifest | map | nil | Signed adapter-route namespace manifest |
app_shell now declares socket as required. sidebar_open and
preferences_manifest have defaults, so omitting them still renders — but loses
the restored sidebar state and the correct first paint after a toggle-and-reload.
Pass them through.
The branding moved out of the topbar into the new <:sidebar_branding> slot and
topbar_branding/1 was renamed to sidebar_branding/1. Your <:sidebar>
content itself does not change.
Before:
<Backpex.HTML.Layout.app_shell fluid={@fluid?} live_resource={@live_resource}>
<:topbar>
<Backpex.HTML.Layout.topbar_branding />
<Backpex.HTML.Layout.theme_selector socket={@socket} themes={[{"Light", "light"}]} />
</:topbar>
<:sidebar>
<Backpex.HTML.Layout.sidebar_section id="blog">
<:label>Blog</:label>
</Backpex.HTML.Layout.sidebar_section>
</:sidebar>
</Backpex.HTML.Layout.app_shell>After:
<Backpex.HTML.Layout.app_shell
socket={@socket}
fluid={@fluid?}
live_resource={@live_resource}
sidebar_open={@sidebar_open}
preferences_manifest={@preferences_manifest}
>
<:topbar>
<div class="flex-1"></div>
<Backpex.HTML.Layout.theme_selector current_theme={@current_theme} themes={[{"Light", "light"}]} />
</:topbar>
<:sidebar_branding>
<Backpex.HTML.Layout.sidebar_branding />
</:sidebar_branding>
<: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>Note two details on sidebar_section/1:
idis now required and must be unique. It becomes the suffix of the preference keyglobal.sidebar_section.<id>and is used foraria-controls, so sections without unique ids would toggle together. Use only[A-Za-z0-9_-]+.sidebar_section_statesmust be passed explicitly. It is a function component, so an omitted attribute does not inherit the surrounding assign — it defaults to%{}, every section server-renders open, and the hook then seeds itself from that markup, so collapsed sections are lost rather than briefly flashed.
Custom layouts: theme_selector is no longer self-contained
In v0.19 the theme selector carried its own endpoint, so it worked in any
layout. In v0.20 every preference write goes through one element that carries
the endpoint, and app_shell/1 renders it. If your layout uses app_shell,
there is nothing to do. If it does not, render
Backpex.HTML.Layout.preferences_root/1 yourself, once per page — otherwise
every preference write is silently dropped. The old
theme_selector socket={@socket} call site still compiles, so this is not
caught at compile time. See
Custom layouts must render preferences_root.
5. Opt in to persisted column and metric visibility
In v0.19, column visibility and metric visibility were stored in the session without an opt-in. In v0.20 persistence is opt-in per resource:
use Backpex.LiveResource,
adapter_config: [...],
persist: [:columns, :metrics]Default is []. Add persist: [:columns, :metrics] to each resource where both
toggles should keep surviving reloads. :order and :filters can be persisted
the same way — see
Opt-in persistence.
6. Default ordering follows primary_key
When init_order is omitted, the index now orders ascending by the
LiveResource's configured primary_key instead of always by :id. Resources
using the default primary_key: :id are unchanged. If a resource sets another
primary key but intentionally sorted by :id, configure that explicitly with
init_order: %{by: :id, direction: :asc}.
7. Existing preference state is not migrated
The new Session adapter reads only the "backpex_preferences" session key. It
does not read the v0.19 "backpex" session tree, and the new hooks do not read
the old localStorage entries. Upgrading therefore resets existing UI choices.
| v0.19 state | v0.20 behavior |
|---|---|
session["backpex"]["theme"], localStorage["backpexTheme"] | Not read; the configured/default theme is used until the user selects one again. |
session["backpex"]["column_toggle"] | Not read; columns use their configured defaults. |
session["backpex"]["metric_visibility"] | Not read; metrics start visible. |
localStorage["sidebar-section-<id>"] | Not read; unknown sections start open. |
Backpex ships no automatic migration because session and scope lifecycles differ
between hosts. If preserving existing choices matters, translate the old tree to
the new keys in your application before clearing it. Remove the legacy
localStorage entries when convenient.
8. The cookie form protocol has been replaced
The old endpoint accepted HTML forms such as select_theme, toggle_columns
and toggle_metrics, then redirected. The new endpoint accepts
{"key": ..., "value": ...} or {"preferences": [...]} and returns JSON.
Renaming an old form action while leaving its fields intact returns
400 {"ok": false, "error": "missing key/value"}.
Before (no longer supported):
<form action={Backpex.Router.cookie_path(@socket)} method="post">
<input type="hidden" name="select_theme" value="dark" />
<button>Dark</button>
</form>After: write from the browser with
BackpexPreferences.set('global.theme', 'dark', { mirror: 'session' }), or from
a LiveView with Backpex.Preferences.put(socket, key, value, mirror: :session).
See Custom preferences
and the HTTP endpoint contract.
Behavior changes without migration steps
Sidebar breakpoint raised from
mdtolg. Tablets between 768–1023 px now see the mobile drawer where they previously saw the inline sidebar.Empty sidebar sections are hidden via CSS.
sidebar_item/1marks its root withdata-sidebar-item. Add that attribute to the root element of custom leaf markup rendered directly inside a section, or the section counts as empty.BackpexSidebarSectionsno longer writesdata-section-open. That attribute is server-owned now. Anything keying CSS or a test off the hook-written value must key offaria-expandedinstead.New short-lived cookie
backpex_prefs. It carries not-yet-acknowledged preference writes so a reload right after a toggle paints the new state (path=/,SameSite=Lax,max-age=300, notHttpOnly). It is a strictly functional cookie — route tokens are keyed digests, not raw user or tenant ids — but confirm the classification your consent policy requires. Details in What Backpex stores in the browser.Preference storage is pluggable. With no config every key routes to the Phoenix session adapter, so no action is required. Route a prefix to a database adapter when you outgrow the ~4 KB cookie ceiling or need preferences to follow a user across devices or stay isolated between tenants: Storage adapters.
Renamed translation msgids. Update your
.pofiles:Removed msgid Replacement msgid Main desktop navigationMain navigationMain mobile navigationMain navigation(merged)Toggle menuToggle sidebar