PhoenixPageMeta

Per-page metadata for Phoenix LiveView apps: breadcrumbs, active-link state, SEO meta tag rendering. One struct per page, one render in the layout, no per-project re-implementation of the same logic in three different places.

Why

Every Phoenix app I write ends up with the same trio: breadcrumb logic somewhere, active-link helpers duplicated across sidebar/nav/layout components, and a layout's worth of meta tags hand-built per project. PhoenixPageMeta standardises all three around a single struct that each LiveView declares once.

The struct lives explicitly in your project — full visibility, no macro that hides what fields you have. The macro injects only the wiring around it (behaviour, helpers, validation).

Installation

def deps do
  [{:phoenix_page_meta, "~> 0.1"}]
end

Setup

One file. No config.exs entry.

defmodule MyAppWeb.PageMeta do
  use PhoenixPageMeta

  @enforce_keys [:title, :path]
  defstruct [
    :title,
    :path,
    :breadcrumb_title,
    :parent,
    :description,
    :og_image,
    :og_image_alt,
    :json_ld,
    :canonical_path,
    :icon,
    :skip_breadcrumb,
    :locale,
    og_type: "website",
    noindex: false,
    supported_locales: [:en, :es],
    site_name: "MyApp",
    twitter_site: "@myapp"
  ]
end

use PhoenixPageMeta injects:

Both defaults are defoverridable if your project deviates.

use options

use PhoenixPageMeta, base_url: "https://example.com"        # explicit string
use PhoenixPageMeta, base_url: &MyAppWeb.Endpoint.url/0     # explicit function capture
use PhoenixPageMeta                                          # auto-guess MyAppWeb.Endpoint

Wire LiveView

In MyAppWeb.live_view/0:

def live_view do
  quote do
    use Phoenix.LiveView, layout: {MyAppWeb.Layouts, :app}
    @behaviour PhoenixPageMeta.LiveView
    import PhoenixPageMeta.LiveView, only: [assign_page_meta: 1]
  end
end

Usage

In each LiveView, implement page_meta/2 and call assign_page_meta/1 after data is loaded:

defmodule MyAppWeb.LocationLive.Show do
  use MyAppWeb, :live_view

  @impl PhoenixPageMeta.LiveView
  def page_meta(socket, :show) do
    location = socket.assigns.location
    %MyAppWeb.PageMeta{
      title: location.name,
      path: ~p"/locations/#{location.slug}",
      description: location.summary,
      parent: %MyAppWeb.PageMeta{title: "Locations", path: ~p"/locations"}
    }
  end

  def handle_params(params, _uri, socket) do
    {:noreply,
     socket
     |> assign(:location, load_location(params))
     |> assign_page_meta()}
  end
end

In your root layout, render the meta tags:

<head>
  <PhoenixPageMeta.Components.MetaTags.default page_meta={@page_meta} />
  <.live_title>{@page_title}</.live_title>
</head>

In nav components, use MyAppWeb.PageMeta.active?/2:

<.link navigate={~p"/locations"} class={MyAppWeb.PageMeta.active?(@page_meta, ~p"/locations") && "active"}>
  Locations
</.link>

For breadcrumbs, use the slot-based component (handles aria-label, aria-current, divider placement):

<PhoenixPageMeta.Components.Breadcrumbs.list page_meta={@page_meta}>
  <:link :let={breadcrumb}>
    <.link navigate={breadcrumb.path} class="hover:underline truncate">
      <.icon :if={breadcrumb.page_meta.icon} name={breadcrumb.page_meta.icon} class="size-4" />
      {breadcrumb.title}
    </.link>
  </:link>
  <:current :let={breadcrumb}>
    <span class="font-medium truncate">{breadcrumb.title}</span>
  </:current>
  <:divider>
    <span class="text-base-content/30">/</span>
  </:divider>
</PhoenixPageMeta.Components.Breadcrumbs.list>

Module structure

PhoenixPageMeta                          # __using__ macro, active?/2,3 (lib-level)
PhoenixPageMeta.Breadcrumb               # struct + build/1
PhoenixPageMeta.Components.Breadcrumbs   # list/1 component
PhoenixPageMeta.Components.MetaTags      # default/1 component (SEO)
PhoenixPageMeta.Site                     # behaviour: base_url/0, lang_path/2
PhoenixPageMeta.LiveView                 # behaviour: page_meta/2 + assign_page_meta/1

The components dispatch site-wide callbacks (base_url, lang_path) via page_meta.__struct__ — no global config needed.

Standard fields

Field Type Notes
:titleString.t() required
:pathString.t() required
:breadcrumb_titleString.t() | nil falls back to :title in breadcrumbs
:parentt() | nil parent page; walked for breadcrumbs (required field for the @after_compile check)
:descriptionString.t() | nil meta description, og:description, twitter:description
:og_imageString.t() | nil OG and Twitter image. Relative paths are auto-prefixed with base_url; absolute URLs (http:///https://) are rendered as-is
:og_image_altString.t() | nil alt text for og:image; falls back to :title
:og_typeString.t() suggested default "website"
:json_ldmap() | nil rendered as <script type="application/ld+json">
:canonical_pathString.t() | nil overrides :path for canonical URL
:noindexboolean() suggested default false
:localeatom() | nil current page's locale; renders og:locale
:supported_locales[atom()] | nil hreflang tags + og:locale:alternate
:site_nameString.t() | nil renders og:site_name. Typically a project-wide defstruct default
:twitter_siteString.t() | nil renders twitter:site (e.g. "@handle"). Typically a project-wide defstruct default
:skip_breadcrumbboolean() | nil when true, this page is filtered out of the breadcrumb (modals, overlays)

MetaTags.default reads optional fields with Map.get/2, so your struct only needs the fields you actually use. Add project-specific fields freely (e.g. :icon, :twitter_handle, :modal).

License

MIT