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
endBecause 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
endThe <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
endThen 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
endThen 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 inhandle_params/3. The static render runs mount before handle_params. - og:image is relative path — always use an absolute URL. The
url/1helper prepends your configured host. - OG tags missing in production — check that
url: [host: "yourapp.com"]is set inconfig/prod.exsunderMyAppWeb.Endpoint.
Summary
- Add default OG tags to
root.html.heexusingassigns[:]with fallbacks. - Set OG assigns in controller actions or LiveView
mount/3. - Use
url/1+ sigil paths for absolute URLs. - Add a dedicated
/og/:slugroute for dynamic OG images. - Preview the result with OG Fixer before announcing your post.