Phoenix LiveView Open Graph Tags: Add OG Meta to HEEx Templates

Add og:title, og:image, og:description and Twitter Card meta tags to Phoenix LiveView apps using HEEx layouts — with copy-paste examples.

Phoenix + HEEx: server-rendered OG tags

Phoenix renders HTML on the server, so social crawlers (Twitter, Slack, Discord) receive complete HTML including all <meta> tags without running JavaScript. This makes Open Graph implementation straightforward — no SSR workarounds needed.

For LiveView pages the approach is the same: OG tags live in the <head> of your root layout, not inside the LiveView socket. Social crawlers hit the initial HTTP response, not the WebSocket.

Root layout: global OG defaults

Open lib/my_app_web/components/layouts/root.html.heex and add a default OG block inside <head>:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <.live_title suffix=" · MyApp">
      <%= assigns[:page_title] || "MyApp" %>
    </.live_title>

    <!-- Open Graph defaults -->
    <meta property="og:site_name"   content="MyApp" />
    <meta property="og:type"        content="website" />
    <meta property="og:url"         content={url(~p"/")} />
    <meta property="og:title"       content={assigns[:og_title]       || "MyApp"} />
    <meta property="og:description" content={assigns[:og_description] || "Your default site description."} />
    <meta property="og:image"       content={assigns[:og_image]       || url(~p"/images/og-default.png")} />

    <!-- Twitter / X Card -->
    <meta name="twitter:card"        content="summary_large_image" />
    <meta name="twitter:title"       content={assigns[:og_title]       || "MyApp"} />
    <meta name="twitter:description" content={assigns[:og_description] || "Your default site description."} />
    <meta name="twitter:image"       content={assigns[:og_image]       || url(~p"/images/og-default.png")} />

    <link rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer src={~p"/assets/app.js"}></script>
  </head>
  <body>
    <%= @inner_content %>
  </body>
</html>

Per-page OG tags in regular controllers

In a standard Phoenix controller action, assign OG values directly:

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller

  def show(conn, %{"id" => id}) do
    post = Blog.get_post!(id)

    conn
    |> assign(:page_title, post.title)
    |> assign(:og_title, post.title)
    |> assign(:og_description, post.excerpt)
    |> assign(:og_image, post.cover_image_url || "/images/og-default.png")
    |> render(:show, post: post)
  end
end

Because conn.assigns are forwarded to the root layout, the assigns[:og_title] expressions in your HEEx template resolve automatically.

Per-page OG tags in LiveView

LiveView mounts run twice: once as a static HTTP request (what crawlers see) and once over a WebSocket. Use mount/3 to set OG assigns — they are available during the initial HTTP render:

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

  def mount(%{"id" => id}, _session, socket) do
    post = Blog.get_post!(id)

    socket =
      socket
      |> assign(:page_title, post.title)
      |> assign(:og_title, post.title)
      |> assign(:og_description, post.excerpt)
      |> assign(:og_image, post.cover_image_url || "/images/og-default.png")
      |> assign(:post, post)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <article>
      <h1><%= @post.title %></h1>
      <p><%= @post.body %></p>
    </article>
    """
  end
end

The <head> in your root layout reads from assigns, which are populated during the static mount. Crawlers never connect via WebSocket, so they receive the correct OG tags on the first response.

Dynamic og:image with a custom route

For auto-generated OG images (e.g. a card with the post title), create a dedicated controller that renders a PNG:

# router.ex
get "/og/:slug", MyAppWeb.OgImageController, :show

# og_image_controller.ex
defmodule MyAppWeb.OgImageController do
  use MyAppWeb, :controller

  def show(conn, %{"slug" => slug}) do
    post = Blog.get_post_by_slug!(slug)
    # Generate PNG bytes with :image, :vix, or an external service
    png_bytes = OgImageRenderer.render(post.title)

    conn
    |> put_resp_content_type("image/png")
    |> send_resp(200, png_bytes)
  end
end

Then reference it in your LiveView mount:

|> assign(:og_image, url(~p"/og/#{post.slug}"))

og:url: use the canonical path

Always set og:url to the canonical URL. In Phoenix you can use the url/1 helper:

<meta property="og:url" content={url(~p"/blog/#{@post.slug}")} />

The url/1 helper automatically uses the host configured in config/prod.exs under MyAppWeb.Endpoint.

Article structured data (JSON-LD)

Add JSON-LD to blog posts for better Google indexing. Create a reusable HEEx component:

# lib/my_app_web/components/seo.ex
defmodule MyAppWeb.Components.Seo do
  use Phoenix.Component

  attr :title,       :string, required: true
  attr :description, :string, required: true
  attr :url,         :string, required: true
  attr :image,       :string, required: true
  attr :published,   :string, required: true

  def article_json_ld(assigns) do
    ~H"""
    <script type="application/ld+json">
      <%= raw(Jason.encode!(%{
        "@context" => "https://schema.org",
        "@type"    => "Article",
        "headline" => @title,
        "description" => @description,
        "url" => @url,
        "image" => @image,
        "datePublished" => @published,
        "author" => %{
          "@type" => "Organization",
          "name"  => "MyApp"
        }
      })) %>
    </script>
    """
  end
end

Then call it from any HEEx template:

<.article_json_ld
  title={@post.title}
  description={@post.excerpt}
  url={url(~p"/blog/#{@post.slug}")}
  image={@og_image}
  published={DateTime.to_iso8601(@post.inserted_at)}
/>

Testing your OG tags

After deployment, paste your URL into OG Fixer to see exactly how your page looks on Twitter, Slack, Discord, and LinkedIn — without waiting for each platform's cache to expire.

You can also curl the page to confirm the raw HTML contains your OG tags:

curl -s https://yourapp.com/blog/your-post | grep 'og:'

Common issues

  • og:image shows old image after deploy — social platforms cache aggressively. Use Facebook's Sharing Debugger or Twitter's Card Validator to force a fresh fetch.
  • LiveView page shows wrong title — ensure assigns are set in mount/3, not only in handle_params/3. The static render runs mount before handle_params.
  • og:image is relative path — always use an absolute URL. The url/1 helper prepends your configured host.
  • OG tags missing in production — check that url: [host: "yourapp.com"] is set in config/prod.exs under MyAppWeb.Endpoint.

Summary

  • Add default OG tags to root.html.heex using assigns[:] with fallbacks.
  • Set OG assigns in controller actions or LiveView mount/3.
  • Use url/1 + sigil paths for absolute URLs.
  • Add a dedicated /og/:slug route for dynamic OG images.
  • Preview the result with OG Fixer before announcing your post.