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.0is an experimental preview. The image pipeline and signed request lifecycle are usable, but the public API may change between0.xreleases.
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
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
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
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
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
- At least one font must be configured.
- Remote
<img>fetching is not implemented yet. - The default cache is local to one BEAM node.
- Simultaneous misses are not yet coalesced.
- Streaming or compressed HTML responses are not rewritten automatically.
- Takumi supports a broad CSS subset, but it is not a browser and will not reproduce every Chromium behavior.
- The
0.1controller API is experimental and is expected to evolve in later0.xreleases.
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.