Add custom pages to the PhoenixKit admin sidebar.

This guide shows you how to create custom admin pages that integrate seamlessly with PhoenixKit's navigation and layout system.


Quick Start

# 1. Create the LiveView
defmodule MyAppWeb.AdminAnalyticsLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, page_title: "Analytics")}
  end

  def render(assigns) do
    ~H"""
    <PhoenixKitWeb.Components.LayoutWrapper.app_layout
      flash={@flash}
      page_title={@page_title}
      current_path={@url_path}
      phoenix_kit_current_scope={@phoenix_kit_current_scope}
      current_locale={assigns[:current_locale]}
    >
      <div class="container flex-col mx-auto px-4 py-6">
        <h1 class="text-2xl font-bold mb-6">Analytics</h1>
      </div>
    </PhoenixKitWeb.Components.LayoutWrapper.app_layout>
    """
  end
end

# 2. Register in config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/analytics",
    permission: "dashboard",
    priority: 150,
    group: :admin_main,
    live_view: {MyAppWeb.AdminAnalyticsLive, :index}
  }
]

Igniter Generator

For automated setup, use the built-in Igniter task to generate admin pages:

mix phoenix_kit.gen.admin_page Analytics Reports "Reports Dashboard" \
  --url="/admin/analytics/reports" \
  --icon="hero-chart-bar"

Arguments

ArgumentDescriptionExample
categoryCategory name for groupingAnalytics
page_nameModule name (PascalCase)Reports
page_titleDisplay title"Reports Dashboard"

Options

OptionRequiredDefaultDescription
--url✅ Yes-URL path (must start with /)
--iconNo"hero-document-text"Heroicon name
--descriptionNo-Brief description
--category-iconNo"hero-folder"Category icon

What It Generates

  1. LiveView module at lib/{app_name}_web/phoenix_kit/live/admin/{category}/{page}.ex
  2. Config entry in config/config.exs for the admin category
  3. Template-based boilerplate code

After Generation

Important: You must add the route to your router manually:

# lib/my_app_web/router.ex
scope "/" do
  pipe_through :browser

  live_session :phoenix_kit_admin_custom_categories,
    on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}] do
    live "/admin/analytics/reports", MyAppWeb.PhoenixKit.Live.Admin.Analytics.Reports, :index
  end
end

Example: Create Analytics Dashboard

mix phoenix_kit.gen.admin_page Analytics Dashboard "Analytics Dashboard" \
  --url="/admin/analytics" \
  --icon="hero-chart-bar" \
  --category-icon="hero-chart-bar"

This generates:

  • Module: MyAppWeb.PhoenixKit.Live.Admin.Analytics.Dashboard
  • Route: /admin/analytics
  • File: lib/my_app_web/phoenix_kit/live/admin/analytics/dashboard.ex

Manual Setup

If you prefer manual setup or need more control, follow these steps:

Your custom admin LiveView needs to use PhoenixKit's layout wrapper and handle the assigns provided by PhoenixKit's on_mount hooks.

Basic Template

# lib/my_app_web/phoenix_kit_live/admin_analytics_live.ex
defmodule MyAppWeb.AdminAnalyticsLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, page_title: "Analytics")}
  end

  def render(assigns) do
    ~H"""
    <PhoenixKitWeb.Components.LayoutWrapper.app_layout
      flash={@flash}
      page_title={@page_title}
      current_path={@url_path}
      phoenix_kit_current_scope={@phoenix_kit_current_scope}
      current_locale={assigns[:current_locale]}
    >
      <div class="container flex-col mx-auto px-4 py-6">
        <h1 class="text-2xl font-bold mb-6">Analytics</h1>
        <!-- Your content here -->
      </div>
    </PhoenixKitWeb.Components.LayoutWrapper.app_layout>
    """
  end
end

Required Assigns

AssignPurposeSource
@flashFlash messages for notificationsPhoenix
@page_titlePage title for browser/tabYour LiveView
@url_pathCurrent request pathPhoenixKit on_mount
@phoenix_kit_current_scopeAuth scope for permissionsPhoenixKit on_mount
assigns[:current_locale]Optional locale for i18nYour app

Registering the Tab

Register your custom page in config/config.exs using the :admin_dashboard_tabs config:

config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,                           # Unique atom ID
    label: "Analytics",                             # Display text
    icon: "hero-chart-bar",                         # Heroicon name
    path: "/admin/analytics",                       # Route path
    permission: "dashboard",                        # Required permission key
    priority: 150,                                  # Sort order (lower = first)
    group: :admin_main,                             # Sidebar group
    live_view: {MyAppWeb.AdminAnalyticsLive, :index}
  }
]

Tab Options

OptionTypeRequiredDescription
idatom✅ YesUnique identifier for the tab (prefix with admin_ by convention)
labelstring✅ YesDisplay text in sidebar
pathstring⚠️ UsuallyRoute path (auto-generated from live_view if provided)
iconstringNoHeroicon name (e.g., "hero-chart-bar")
permissionstring⚠️ RecommendedPermission key for access control
priorityintegerNoSort order (default: 500, lower = higher in sidebar)
groupatomNoSidebar group (default: :admin_main)
parentatomNoParent tab ID for subtab relationships
matchatomNoPath matching: :exact, :prefix, or {:regex, ~r/...}
visiblefunctionNo(scope -> boolean) for conditional visibility
live_viewtuple⚠️ Recommended{Module, :action} to auto-generate route
subtab_displayatomNo:when_active or :always (default: :when_active)
highlight_with_subtabsbooleanNoHighlight parent when subtab is active

Using live_view for Seamless Navigation

When you provide the live_view tuple, PhoenixKit automatically generates a route inside the shared admin live_session. This means:

  • ✅ No full page reload when navigating from other admin pages
  • ✅ Preserves live navigation state
  • ✅ Consistent with built-in PhoenixKit admin pages

If you omit live_view, you must define the route manually in your router.


PhoenixKit organizes admin tabs into groups for better organization:

GroupDescriptionExample Tabs
:admin_mainPrimary admin functionsDashboard, Users, Settings
:admin_contentContent managementEntities, Publishing
:admin_modulesFeature modulesAI, Billing, Commerce
:admin_systemSystem-levelLogs, Background Jobs
# Content group example
%{
  id: :blog_posts,
  label: "Blog Posts",
  icon: "hero-document-text",
  path: "/admin/blog",
  permission: "entities",
  group: :admin_content,  # <-- Groups under "Content"
  live_view: {MyAppWeb.BlogPostsLive, :index}
}

Permission Gates

Simple Permission Check

Use the permission option to restrict access:

%{
  id: :admin_billing,
  label: "Billing",
  icon: "hero-credit-card",
  path: "/admin/billing",
  permission: "billing",  # Users need "billing" permission
  live_view: {MyAppWeb.BillingLive, :index}
}

In-LiveView Permission Check

For additional permission checks within your LiveView:

def mount(_params, _session, socket) do
  scope = socket.assigns.phoenix_kit_current_scope

  if PhoenixKit.Users.Auth.Scope.system_role?(scope) or
     PhoenixKit.Users.Auth.Scope.has_module_access?(scope, "billing") do
    {:ok, assign(socket, page_title: "Billing")}
  else
    {:ok, redirect_or_show_error(socket)}
  end
end

Common Patterns

Data Fetching in mount/3

def mount(_params, _session, socket) do
  # Fetch your data
  products = MyApp.Catalog.list_products()

  {:ok, assign(socket,
    page_title: "Products",
    products: products
  )}
end

Handle Events

def handle_event("delete_product", %{"id" => id}, socket) do
  {:ok, _product} = MyApp.Catalog.delete_product(id)

  {:noreply, put_flash(socket, :info, "Product deleted")}
end

Pagination

def mount(params, _session, socket) do
  page = String.to_integer(params["page"] || "1")
  per_page = 20

  {products, pagination} = MyApp.Catalog.paginate_products(page, per_page)

  {:ok, assign(socket,
    page_title: "Products",
    products: products,
    pagination: pagination
  )}
end

Full Example: Blog Posts Admin

# lib/my_app_web/phoenix_kit_live/admin_blog_posts_live.ex
defmodule MyAppWeb.AdminBlogPostsLive do
  use MyAppWeb, :live_view

  alias MyApp.Blog

  def mount(_params, _session, socket) do
    posts = Blog.list_posts()
    {:ok, assign(socket, posts: posts, page_title: "Blog Posts")}
  end

  def render(assigns) do
    ~H"""
    <PhoenixKitWeb.Components.LayoutWrapper.app_layout
      flash={@flash}
      page_title={@page_title}
      current_path={@url_path}
      phoenix_kit_current_scope={@phoenix_kit_current_scope}
      current_locale={assigns[:current_locale]}
    >
      <div class="container mx-auto px-4 py-6">
        <div class="flex justify-between items-center mb-6">
          <h1 class="text-2xl font-bold">Blog Posts</h1>
          <.link navigate="/admin/blog/new" class="btn btn-primary">
            New Post
          </.link>
        </div>

        <table class="table table-zebra">
          <thead>
            <tr>
              <th>Title</th>
              <th>Status</th>
              <th>Date</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            <tr :for={post <- @posts}>
              <td><%= post.title %></td>
              <td><%= post.status %></td>
              <td><%= post.published_at %></td>
              <td class="flex gap-2">
                <.link navigate={"/admin/blog/#{post.id}/edit"} class="btn btn-xs">
                  Edit
                </.link>
                <button phx-click="delete" phx-value-id={post.id} class="btn btn-xs btn-error">
                  Delete
                </button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </PhoenixKitWeb.Components.LayoutWrapper.app_layout>
    """
  end

  def handle_event("delete", %{"id" => id}, socket) do
    {:ok, _post} = Blog.delete_post(id)
    posts = Blog.list_posts()
    {:noreply, assign(socket, posts: posts) |> put_flash(:info, "Post deleted")}
  end
end

# config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
  # ... other tabs
  %{
    id: :admin_blog_posts,
    label: "Blog Posts",
    icon: "hero-document-text",
    path: "/admin/blog",
    permission: "entities",
    group: :admin_content,
    live_view: {MyAppWeb.AdminBlogPostsLive, :index}
  }
]

Manual Route Definition

If you don't use the live_view option, define routes manually:

# lib/my_app_web/router.ex
import PhoenixKitWeb.Integration

scope "/", MyAppWeb do
  pipe_through [:browser, :require_authenticated_user, :phoenix_kit_ensure_admin]

  live "/admin/custom", CustomAdminLive
end

Note: Manual routes won't get seamless LiveView navigation from other admin pages. Prefer the live_view option when possible.


See also: Admin Navigation Reference for complete tab system documentation.

Last Updated: 2026-03-02