Bump Your Deps

Update Backpex to the latest version:

defp deps do
  [
    {:backpex, "~> 0.20.0"}
  ]
end

v0.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
  end

backpex_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()
end

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

2. 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:

AssignTypeDescription
@current_themestringThe user's selected theme
@sidebar_openbooleanWhether the sidebar is open (desktop)
@sidebar_section_statesmapMap of section IDs to open/closed state
@preferences_manifestmap | nilSigned 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:

  • id is now required and must be unique. It becomes the suffix of the preference key global.sidebar_section.<id> and is used for aria-controls, so sections without unique ids would toggle together. Use only [A-Za-z0-9_-]+.
  • sidebar_section_states must 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 statev0.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.

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 md to lg. 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/1 marks its root with data-sidebar-item. Add that attribute to the root element of custom leaf markup rendered directly inside a section, or the section counts as empty.

  • BackpexSidebarSections no longer writes data-section-open. That attribute is server-owned now. Anything keying CSS or a test off the hook-written value must key off aria-expanded instead.

  • 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, not HttpOnly). 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 .po files:

    Removed msgidReplacement msgid
    Main desktop navigationMain navigation
    Main mobile navigationMain navigation (merged)
    Toggle menuToggle sidebar