PhoenixPageMeta

Hex.pmHexdocsCILicense

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

With Igniter, the installer sets up the PageMeta struct, root.html.heex meta tags, and LiveView wiring automatically:

mix igniter.install phoenix_page_meta

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

FieldTypeNotes
:titleString.t()required
:pathString.t()required
:breadcrumb_titleString.t() | nilfalls back to :title in breadcrumbs
:parentt() | nilparent page; walked for breadcrumbs (required field for the @after_compile check)
:descriptionString.t() | nilmeta description, og:description, twitter:description
:og_imageString.t() | nilOG and Twitter image. Relative paths are auto-prefixed with base_url; absolute URLs (http:///https://) are rendered as-is
:og_image_altString.t() | nilalt text for og:image; falls back to :title
:og_typeString.t()suggested default "website"
:json_ldmap() | nilrendered as <script type="application/ld+json">
:canonical_pathString.t() | niloverrides :path for canonical URL
:noindexboolean()suggested default false
:localeatom() | nilcurrent page's locale; renders og:locale
:supported_locales[atom()] | nilhreflang tags + og:locale:alternate
:site_nameString.t() | nilrenders og:site_name. Typically a project-wide defstruct default
:twitter_siteString.t() | nilrenders twitter:site (e.g. "@handle"). Typically a project-wide defstruct default
:skip_breadcrumbboolean() | nilwhen 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