OgEx

OgEx generates Open Graph and Twitter/X card images from HEEx inside Phoenix controllers. A page declares its card in the normal render/3 call; OgEx adds the metadata and serves the generated image from a signed version of the same page URL.

render(conn, :show, post: post, og: MyAppWeb.PostOgCard)

Application code does not define an image route or image controller.

Project status:0.1.0 is an experimental preview. The image pipeline and signed request lifecycle are usable, but the public API may change between 0.x releases.

How it works

For a normal page request:

GET /posts/42
→ controller loads the post
→ OgEx builds metadata and a signed image URL
→ Phoenix renders the normal page
→ OgEx inserts social metadata before </head>

The generated og:image points to the same route with a compact 22-character signature:

GET /posts/42?__og_ex=4K7fQxRfj2p0DqX_WLAzTA

That request runs the controller action again. When execution reaches the OgEx-aware render/3, it lazily fetches and verifies the signature, renders the card's HEEx through Takumi, caches the encoded image, and sends it with the configured image content type.

Installation

Add OgEx to mix.exs:

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

During local package development:

{:og_ex, path: "../og-ex"}

Stable releases download a checksum-verified precompiled NIF for supported Linux, macOS, and Windows targets, so consuming applications do not need Rust. Development versions and forced source builds require the Rust version pinned in rust-toolchain.toml because Takumi 2.4 requires it. Set OG_EX_BUILD=true when explicitly testing the source-build path.

No endpoint plug or generated route is required. The controller integration fetches the reserved query parameter lazily only when render/3 is reached.

Configure at least one font. Takumi shapes text from supplied font data rather than relying on fonts installed on the host:

config :og_ex,
fonts: [
Path.expand("../priv/fonts/Inter-Regular.ttf", __DIR__),
Path.expand("../priv/fonts/Inter-Bold.ttf", __DIR__)
]

Then enable the controller integration:

defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
use OgEx.Controller
def show(conn, %{"id" => id}) do
post = MyApp.Blog.get_post!(id)
render(conn, :show,
post: post,
og: MyAppWeb.PostOgCard
)
end
end

Defining a card

A card keeps metadata and presentation in one module:

defmodule MyAppWeb.PostOgCard do
use OgEx.Card, width: 1200, height: 630, format: :png
@impl OgEx.Card
def metadata(%{post: post}) do
%{
title: post.title,
description: post.summary,
type: "article",
image_alt: "Social card for #{post.title}",
twitter_card: "summary_large_image"
}
end
# Only fields that change the image need to contribute to its public version.
# A new version creates a new immutable image URL and cache entry.
@impl OgEx.Card
def version(%{post: post}) do
{post.id, post.title, post.updated_at}
end
@impl OgEx.Card
def render(assigns) do
~H"""
<main class="card">
<p class="site">EXAMPLE.COM</p>
<section>
<h1>{@post.title}</h1>
<p class="author">By {@post.author.name}</p>
</section>
</main>
<style>
* {
box-sizing: border-box;
}
.card {
width: 100%;
height: 100%;
padding: 72px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: white;
background:
radial-gradient(circle at top right, #4f46e5, transparent 45%),
#0f172a;
font-family: Inter, sans-serif;
}
.site {
font-size: 26px;
font-weight: 700;
letter-spacing: 0.16em;
}
h1 {
max-width: 1000px;
margin: 0 0 24px;
font-size: 72px;
line-height: 1.05;
}
.author {
color: #c7d2fe;
font-size: 30px;
}
</style>
"""
end
end

This is ordinary HEEx, HTML, and CSS. OgEx extracts card-local <style> blocks and sends them through Takumi's selector and cascade engine.

The dimensions declared in use OgEx.Card are the single source of truth. OgEx applies them to the renderer viewport and generated HTML document, while the card root fills that viewport with width: 100% and height: 100%.

Output formats

Cards support :png, :jpeg, :webp, and :svg:

use OgEx.Card, width: 1200, height: 630, format: :svg

SVG output is a real vector document produced from the same HTML/CSS layout, including glyph-outline text and vector backgrounds, borders, gradients, shadows, clipping, and transforms. Embedded bitmap images remain raster content inside the SVG. PNG remains the safest default for Open Graph crawlers because SVG acceptance varies across social platforms.

Controller examples and generated outputs

These examples come from the companion Phoenix demo. Each action selects a card in its normal page render/3 call; OgEx serves the image from that same route without an image controller or an image route.

Wide PNG

def home(conn, _params) do
render(conn, :home,
title: "OgEx is working",
description: "Open Graph images generated from HEEx and rendered by Takumi.",
og: MyAppWeb.HomeOgCard
)
end

Wide 1200 by 630 PNG generated by the controller example

Square PNG

def square(conn, _params) do
render(conn, :square,
title: "Small card, clear signal",
description: "A square OgEx image paired with Twitter's compact summary card.",
issue: "02",
og: MyAppWeb.SquareOgCard
)
end

Square 600 by 600 PNG generated by the controller example

Wide SVG

def svg(conn, _params) do
render(conn, :svg,
title: "Signals stay sharp",
description: "One HEEx layout, emitted as a real 1200 × 630 vector document.",
og: MyAppWeb.WideSvgOgCard
)
end

Wide 1200 by 630 SVG generated by the controller example

Square SVG

def square_svg(conn, _params) do
render(conn, :square_svg,
title: "Vector, pocket sized",
description: "A compact summary card whose typography and shapes remain editable SVG.",
og: MyAppWeb.SquareSvgOgCard
)
end

Square 600 by 600 SVG generated by the controller example

Renderer

The default renderer is:

config :og_ex,
renderer: OgEx.Renderer.Takumi

The native pipeline is:

HEEx safe data
→ HTML
→ html5ever parser
→ Takumi layout and text shaping
→ Takumi raster or vector renderer
→ PNG/JPEG/WebP encoder or SVG document
→ Erlang binary

The NIF runs on Rustler's dirty CPU scheduler so image work does not block the BEAM's normal schedulers.

Alternative renderers implement:

@behaviour OgEx.Renderer
@impl true
def render(html, options) do
# Return {:ok, encoded_image_binary} or {:error, reason}.
end

Caching

OgEx starts an ETS cache by default:

config :og_ex,
cache: OgEx.Cache.ETS

Successful images receive:

Cache-Control: public, max-age=31536000, immutable
ETag: "CONTENT_VERSION"

Only successfully encoded images are cached. The cache behavior is replaceable for shared or persistent storage.

Custom cache modules implement OgEx.Cache. Its lookup follows the conventional Elixir fetch/1 contract:

@impl OgEx.Cache
def fetch(key) do
case lookup(key) do
{:found, image} -> {:ok, image}
:not_found -> :error
end
end

{:ok, image} means the encoded image was found, while :error means the key is absent. The [:og_ex, :cache, :miss] telemetry event still uses “miss” as the conventional name for an unsuccessful cache lookup; it is not the return value of fetch/1.

Current limitations

Development

mix deps.get
mix test
cargo test --manifest-path native/og_ex_native/Cargo.toml

The integration suite performs an actual native render and verifies the output PNG signature and dimensions.

Every public and private function is described in the function reference. Public APIs additionally have inline ExDoc documentation, while private helpers have adjacent source comments.

License

MIT.