GUIDE · MAR 2026

Add Dynamic OG Images to Flask and Python Apps

Flask does not have a metadata API. There is no framework convention for injecting og:image tags—you do it in Jinja2, manually, per route. This guide shows you exactly how to structure that cleanly, how to build OG image URLs in Python, and why reaching for Pillow or headless Chrome is usually the wrong call when a URL-based API solves the same problem in five lines.

OG image preview for this article generated by OGPeek

Why Flask Apps Rarely Have Good OG Images

Flask is minimal by design. It gives you routing, request handling, and a templating engine. Everything else is your problem. That is usually a feature, but it means there is no built-in mechanism to remind you to add og:image tags—and no convention that forces every route to have one.

The result is predictable: most Flask apps have a single static OG image defined in the base template, if they have one at all. Every page shares the same social preview card. When someone shares a blog post, a product page, or a user profile, the card shows the homepage image. That is a wasted opportunity on every share.

The reason developers do not fix this is that image generation in Python has historically meant one of three painful options:

  1. Pillow (PIL): Write canvas drawing code, load TTF fonts, handle text wrapping, export PNG, serve from Flask. Works, but 80+ lines of code per image style and system font dependencies on your server.
  2. Headless Chrome / Puppeteer: Render HTML to a PNG. Powerful but adds a 150–300 MB Chrome binary to your deployment, requires a subprocess call per image, and is slow (500ms+ per render).
  3. Pregenerate images at build time: Works for static sites, useless for user-generated content or dynamic routes.

A URL-based API removes all three options from consideration. You build a URL string, embed it in a meta tag, and move on. The image generation happens elsewhere, the output is cached by the CDN, and your server does nothing.

The OGPeek URL Structure

OGPeek generates 1200×630 PNG images from URL parameters. A complete request looks like this:

https://todd-agent-prod.web.app/api/v1/og
  ?title=My+Blog+Post+Title
  &subtitle=Technology+%C2%B7+5+min+read
  &template=gradient
  &theme=midnight
  &brandColor=%23F59E0B

The key parameters are:

Because it is a GET request that returns an image, any social crawler can fetch it directly. No authentication, no session cookies, no JavaScript required on the client side.

Building OG URLs in Python

The safest way to build query strings in Python is urllib.parse.urlencode(). It handles special characters, spaces, and Unicode correctly without you thinking about it:

from urllib.parse import urlencode

OGPEEK_BASE = "https://todd-agent-prod.web.app/api/v1/og"

def og_image_url(title, subtitle=None, brand_color="#F59E0B",
                 template="gradient", theme="midnight"):
    params = {
        "title": title,
        "template": template,
        "theme": theme,
        "brandColor": brand_color,
    }
    if subtitle:
        params["subtitle"] = subtitle
    return f"{OGPEEK_BASE}?{urlencode(params)}"

This helper takes plain Python strings and returns a URL-safe string with everything encoded. Titles containing &, #, slashes, or non-ASCII characters will all encode correctly. Never build OG image URLs with f-string concatenation—a post title like "Flask & Django: A Comparison" will break your meta tag if the ampersand is not encoded.

Why not just use f-strings? f"...?title={title}" will silently produce invalid URLs when title contains &, =, +, #, or non-ASCII characters. urlencode() handles all of these correctly and costs nothing extra.

Wiring It Into Flask Routes

The standard Flask pattern is to pass template variables in render_template(). Add your OG image URL to those variables and render it in your base template's <head>:

Step 1: Create a base template with OG meta tags

{# templates/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ page_title }} — MySite</title>
  <meta name="description" content="{{ page_description }}">

  {# Open Graph #}
  <meta property="og:title" content="{{ page_title }}">
  <meta property="og:description" content="{{ page_description }}">
  <meta property="og:type" content="{{ og_type | default('website') }}">
  <meta property="og:url" content="{{ request.url }}">
  <meta property="og:image" content="{{ og_image }}">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">

  {# Twitter Card #}
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="{{ page_title }}">
  <meta name="twitter:description" content="{{ page_description }}">
  <meta name="twitter:image" content="{{ og_image }}">
</head>
<body>
  {% block content %}{% endblock %}
</body>
</html>

Step 2: Pass og_image from each view function

# app.py
from flask import Flask, render_template
from urllib.parse import urlencode

app = Flask(__name__)

OGPEEK_BASE = "https://todd-agent-prod.web.app/api/v1/og"

def og_image_url(title, subtitle=None, brand_color="#F59E0B"):
    params = {
        "title": title,
        "template": "gradient",
        "theme": "midnight",
        "brandColor": brand_color,
    }
    if subtitle:
        params["subtitle"] = subtitle
    return f"{OGPEEK_BASE}?{urlencode(params)}"


@app.route("/")
def index():
    return render_template(
        "index.html",
        page_title="MySite",
        page_description="Build something people want.",
        og_image=og_image_url("MySite", "Build something people want."),
    )


@app.route("/blog/<slug>")
def blog_post(slug):
    post = get_post(slug)  # your DB/CMS call
    return render_template(
        "blog_post.html",
        page_title=post.title,
        page_description=post.excerpt,
        og_type="article",
        og_image=og_image_url(post.title, post.category),
        post=post,
    )


@app.route("/products/<product_id>")
def product(product_id):
    p = get_product(product_id)
    return render_template(
        "product.html",
        page_title=p.name,
        page_description=p.tagline,
        og_image=og_image_url(p.name, f"${p.price} · {p.category}"),
        product=p,
    )

Each route builds an OG image URL that reflects the specific page being served. A blog post about Flask gets an OG card with the post title. A product page for a $49 plugin gets a card with the product name and price. Every shared link looks intentional rather than accidental.

Setting a Default Fallback

For routes where you forget to pass og_image, or for pages deep in your app that are rarely shared, use Jinja2's default() filter:

{# In base.html — use a fallback if og_image is not set #}
<meta property="og:image"
  content="{{ og_image | default('https://todd-agent-prod.web.app/api/v1/og?title=MySite&subtitle=Build+something&template=gradient&theme=midnight&brandColor=%23F59E0B') }}">

This way, every page in your app has at minimum a branded fallback card, even routes you have not explicitly wired up yet.

Using a Flask Application Factory Pattern

If you use Flask's application factory pattern with blueprints, the cleanest place to register the OG helper is as a Jinja2 global function. That way every template in every blueprint has access to it without passing it explicitly:

# app/__init__.py
from flask import Flask
from urllib.parse import urlencode

OGPEEK_BASE = "https://todd-agent-prod.web.app/api/v1/og"

def create_app():
    app = Flask(__name__)

    # Register og_image_url as a Jinja2 global
    @app.template_global()
    def og_image_url(title, subtitle=None, brand_color="#F59E0B",
                     template="gradient", theme="midnight"):
        params = {
            "title": title,
            "template": template,
            "theme": theme,
            "brandColor": brand_color,
        }
        if subtitle:
            params["subtitle"] = subtitle
        return f"{OGPEEK_BASE}?{urlencode(params)}"

    from .routes import main_bp, blog_bp
    app.register_blueprint(main_bp)
    app.register_blueprint(blog_bp, url_prefix="/blog")

    return app

With og_image_url registered as a template global, you can call it directly inside any Jinja2 template without passing it from the view:

{# templates/blog/post.html #}
{% extends "base.html" %}

{% set og_image = og_image_url(post.title, post.category) %}

{% block content %}
  <h1>{{ post.title }}</h1>
  <p>{{ post.content }}</p>
{% endblock %}

Blueprint note: The {% set og_image = ... %} assignment at the top of the child template sets a variable that is visible to the parent base.html via template inheritance. This works because Jinja2 variable scope in {% set %} blocks at the top level of a template is visible to parent templates during extension.

Pillow vs OGPeek: A Practical Comparison

Pillow is the go-to Python image library. It can absolutely generate OG images. Here is what that looks like in practice, and why most developers should skip it for this use case.

Generating OG Images with Pillow

# The Pillow approach — and why it is more work than it looks
from PIL import Image, ImageDraw, ImageFont
import textwrap
import io

def generate_og_image_pillow(title, subtitle=None):
    # Create canvas
    img = Image.new("RGB", (1200, 630), color="#09090B")
    draw = ImageDraw.Draw(img)

    # Load fonts — these TTF files must exist on your server
    try:
        title_font = ImageFont.truetype("/usr/share/fonts/truetype/inter/Inter-Bold.ttf", 64)
        subtitle_font = ImageFont.truetype("/usr/share/fonts/truetype/inter/Inter-Regular.ttf", 32)
    except IOError:
        # Falls back to default bitmap font — looks terrible
        title_font = ImageFont.load_default()
        subtitle_font = ImageFont.load_default()

    # Draw gradient background manually (Pillow has no gradient primitive)
    for y in range(630):
        r = int(9 + (y / 630) * 20)
        g = int(9 + (y / 630) * 10)
        b = int(11 + (y / 630) * 20)
        draw.line([(0, y), (1200, y)], fill=(r, g, b))

    # Wrap title text manually — no automatic word wrap
    wrapped = textwrap.fill(title, width=28)
    draw.multiline_text((80, 200), wrapped, font=title_font,
                        fill="#F0F0F2", spacing=12)

    if subtitle:
        draw.text((80, 400), subtitle, font=subtitle_font, fill="#9B9BA7")

    # Accent bar
    draw.rectangle([(80, 160), (160, 168)], fill="#F59E0B")

    # Save to bytes
    buf = io.BytesIO()
    img.save(buf, format="PNG", optimize=True)
    buf.seek(0)
    return buf

That is about 45 lines of code, and it produces a mediocre result. The gradient is a hack, the typography is limited to whatever TTF files you have installed on the server, and text wrapping requires manual tuning. When you change the design, you rewrite Python code and redeploy.

The Same Thing with OGPeek

from urllib.parse import urlencode

def og_image_url(title, subtitle=None):
    params = {"title": title, "template": "gradient",
              "theme": "midnight", "brandColor": "#F59E0B"}
    if subtitle:
        params["subtitle"] = subtitle
    return f"https://todd-agent-prod.web.app/api/v1/og?{urlencode(params)}"

Seven lines. No fonts to install, no image generation on your server, no Flask route to serve the PNG from, no memory overhead per request. When the design changes, you change the template or theme parameter.

Aspect Pillow OGPeek
Lines of code 45–100+ 7
Server dependencies Pillow, TTF fonts, libjpeg None
Memory per request ~10–50 MB (image canvas) 0 MB (URL string)
Render time 100–500ms on server Async, cached at CDN
Typography quality Depends on server fonts Web-quality rendering
Design iteration Rewrite Python + redeploy Change a URL parameter
Custom designs Unlimited (write any code) Template parameters
Works on serverless Harder (binary deps) Yes (just a URL)

When does Pillow make sense? If you need OG images that embed user-uploaded photos, chart visualizations, or layouts that go far beyond text-on-background, Pillow gives you full control. For the common case of branded title cards—blog posts, documentation, product pages, user profiles—a URL-based API is the right tool.

Per-Route OG Images with Flask Blueprints

Real Flask apps are structured with blueprints. Here is a complete blueprint example for a blog section, showing how OG images flow from the database through the view and into the template:

# blog/routes.py
from flask import Blueprint, render_template, abort
from urllib.parse import urlencode
from .models import Post

blog_bp = Blueprint("blog", __name__, template_folder="templates/blog")
OGPEEK_BASE = "https://todd-agent-prod.web.app/api/v1/og"


def build_og_url(title, subtitle=None):
    params = {
        "title": title[:60],  # Trim to safe length
        "template": "gradient",
        "theme": "midnight",
        "brandColor": "#F59E0B",
    }
    if subtitle:
        params["subtitle"] = subtitle[:80]
    return f"{OGPEEK_BASE}?{urlencode(params)}"


@blog_bp.route("/")
def index():
    posts = Post.query.order_by(Post.published_at.desc()).limit(20).all()
    return render_template(
        "blog/index.html",
        posts=posts,
        page_title="Blog",
        page_description="Articles on Python, Flask, and web development.",
        og_image=build_og_url("Blog", "Python · Flask · Web Dev"),
    )


@blog_bp.route("/<slug>")
def post(slug):
    p = Post.query.filter_by(slug=slug, published=True).first_or_404()
    subtitle = f"{p.category} · {p.read_time} min read"
    return render_template(
        "blog/post.html",
        post=p,
        page_title=p.title,
        page_description=p.excerpt,
        og_type="article",
        og_image=build_og_url(p.title, subtitle),
    )

Handling Special Characters in Titles

Python post titles often contain characters that need encoding: em dashes, curly quotes, colons, ampersands. The urlencode() function handles all of these, but there is one subtlety worth knowing.

By default, urlencode() encodes spaces as + rather than %20. Most URL parsers handle both equivalently in query strings, and OGPeek accepts both. If you ever need percent-encoding specifically, use quote_via=quote:

from urllib.parse import urlencode, quote

# Default: spaces become +
urlencode({"title": "Hello World"})
# → "title=Hello+World"

# Explicit percent-encoding
urlencode({"title": "Hello World"}, quote_via=quote)
# → "title=Hello%20World"

# Both work with OGPeek. Stick with the default.

For titles that include HTML entities from your database—things like &amp; or &mdash;—decode them before passing to urlencode(). Otherwise your OG image will literally render "&amp;" instead of "&":

import html
from urllib.parse import urlencode

raw_title = "Flask &amp; Django: A Comparison"
clean_title = html.unescape(raw_title)  # → "Flask & Django: A Comparison"
og_url = f"{OGPEEK_BASE}?{urlencode({'title': clean_title})}"
# title=Flask+%26+Django%3A+A+Comparison  ✓

Flask-Login and User Profile Pages

User profile pages are an underutilized OG image opportunity. When someone shares a link to their profile, a generic card does nothing for your product. A card with their name and role is a quiet piece of marketing every time a profile gets linked:

@app.route("/u/<username>")
def user_profile(username):
    user = User.query.filter_by(username=username).first_or_404()
    subtitle = f"{user.role} · {user.post_count} posts"
    return render_template(
        "profile.html",
        user=user,
        page_title=user.display_name,
        page_description=user.bio or f"View {user.display_name}'s profile.",
        og_image=og_image_url(user.display_name, subtitle),
    )

Testing OG Tags in Flask

Before shipping, verify your tags are rendering correctly. A quick check with curl is faster than opening a browser:

# Check that og:image appears in the rendered HTML
curl -s http://localhost:5000/blog/my-post | grep -o 'og:image.*content="[^"]*"'

# Or use Python's http.client for a one-liner
python -c "
import urllib.request
html = urllib.request.urlopen('http://localhost:5000/blog/my-post').read().decode()
import re
tags = re.findall(r'property=\"og:[^\"]+\"[^>]+content=\"([^\"]+)\"', html)
for t in tags: print(t)
"

For production verification, social platform tools are the authoritative source:

Social platforms cache OG images aggressively—often for days. If you update your OG tags and the old image keeps showing, use the platform's debug/scrape tool to force a re-fetch. Changing the URL parameters (appending &v=2) also busts the cache on most platforms.

See what your OG images look like before shipping

Build and preview OG image URLs interactively. No account required, 50 free images per day.

Try the playground →

Wrapping Up

Flask gives you full control over what goes into your HTML, which means OG image support is entirely in your hands. The setup is straightforward: a helper function that builds OGPeek URLs from Python strings, variables passed through render_template(), and a base template that renders them into <meta> tags.

The Pillow alternative is technically viable but costly. Image generation is CPU and memory intensive, requires system dependencies, and still produces worse-looking results than a properly designed template. For any Flask app where the goal is simply a branded, readable social card for each page, building the URL and letting OGPeek handle the rendering is the pragmatic choice.

If you are working in a different Python context, the same approach applies: Django template tags, FastAPI Jinja2Templates, Starlette, Bottle—anywhere you can inject a variable into an HTML template, og_image_url() works unchanged.

For more on OG images, see the complete OGPeek API reference, the image size and format guide, or the guide to dynamic OG images in Next.js App Router if you are also running a Node.js frontend.