TL;DR: define a content module mapping keys to titles and descriptions, point OGMate at it, mount a plug to serve /images/og/<key>.png, drop a meta tag in your layout. Images are built at compile time and served straight from memory.

OG images are those preview cards you see when sharing links on social media. They don't help SEO but they make your links look intentional. OGMate handles the build pipeline, you provide the content.

This walks through the full setup, framework-agnostic. Works inside a Phoenix endpoint, but Phoenix isn't required.

1. Add the dependency

def deps do
  [
    {:og_mate, "~> 0.1.0"}
  ]
end

2. Content module

Map URL slugs to {title, description}. Return :error for anything unknown, those fall through to the default image at runtime.

defmodule MyApp.OGContent do
  @static_content %{
    "home" => {"MyApp", "Welcome to MyApp."},
    "about" => {"About", "What MyApp does."}
  }

  def content_for(key) when is_map_key(@static_content, key),
    do: Map.fetch!(@static_content, key)

  def content_for("posts/" <> id) do
    case MyApp.Blog.find_by_id(id) do
      nil -> :error
      post -> {post.title, post.description}
    end
  end

  def content_for(_), do: :error
end

Static map for fixed pages, pattern-matched clauses for dynamic content. The catch-all :error clause at the bottom handles anything the rest didn't.

3. The OGMate module

defmodule MyApp.OGImage do
  use OGMate,
    all_keys:
      ["home", "about"] ++ Enum.map(MyApp.Blog.all_posts(), &"posts/#{&1.id}"),
    content_for: MyApp.OGContent,
    theme: [
      background: "#0a0a0a",
      foreground: "#ffffff",
      font: "Inter",
      secondary: "#a3a3a3",
      logo: "priv/static/images/logo.png",
      site_name: "myapp.com"
    ],
    default: {"MyApp", "Welcome to MyApp."},
    dev_mode: Application.compile_env(:my_app, :og_image_dev_mode, false)

  def path_for(key) when is_binary(key), do: "/images/og/#{key}.png"
end

all_keys is computed at compile time. MyApp.Blog.all_posts() is a normal call to an already-compiled module, the result gets inlined as a literal list. New posts means a recompile, but Phoenix code reloading handles that for you in dev.

path_for/1 keeps URL building in one place so the plug and your templates stay in sync.

4. The plug

A small plug intercepts /images/og/<key>.png and serves the bytes.

defmodule MyApp.Plugs.OGImage do
  @behaviour Plug
  import Plug.Conn

  @impl Plug
  def init(opts), do: opts

  @impl Plug
  def call(%Plug.Conn{request_path: "/images/og/" <> rest} = conn, _) do
    key = String.replace_suffix(rest, ".png", "")
    {:ok, bytes} = MyApp.OGImage.image_for(key)

    conn
    |> put_resp_content_type("image/png")
    |> put_resp_header("cache-control", "public, max-age=31536000")
    |> send_resp(200, bytes)
    |> halt()
  end

  def call(conn, _), do: conn
end

That cache-control is a year, which is fine because the URLs are content-keyed. If the content changes, the key changes.

5. Mount the plug

In Phoenix, drop it into the endpoint:

# lib/my_app_web/endpoint.ex
plug MyApp.Plugs.OGImage
plug Plug.Static, ...

Before Plug.Static so OG requests are caught first. Outside Phoenix, add it to your Plug.Router.

6. Reference in your HTML

<meta property="og:image" content={MyApp.OGImage.path_for(@og_key)} />

Set @og_key per route in your controller. "home" for the homepage, "posts/#{post.id}" for a post page.

7. Dev mode

Building everything at compile time is great for production. But rendering all your OG images on every save while you're iterating gets old fast. Set dev_mode: true and OGMate skips that at compile time, rendering lazily on each image_for/1 call instead. New content shows up immediately, no recompile.

# config/dev.exs
config :my_app, og_image_dev_mode: true
# config/prod.exs
config :my_app, og_image_dev_mode: false

The Application.compile_env(:my_app, :og_image_dev_mode, false) call in step 3 reads this at compile time.

See also