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.
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:
- 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.
- 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).
- 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:
title— The main headline, rendered large. Keep it under 60 characters.subtitle— Secondary text below the title. Good for category, author, or date.template— Visual layout. Options includegradient,minimal,split.theme— Color theme.midnight,dawn,slate.brandColor— Accent color as a URL-encoded hex value.%23F59E0Bis amber.
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 & or ——decode them before passing to urlencode(). Otherwise your OG image will literally render "&" instead of "&":
import html
from urllib.parse import urlencode
raw_title = "Flask & 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:
- Twitter/X:
cards-dev.twitter.com/validator - Facebook/LinkedIn:
developers.facebook.com/tools/debug/ - Slack: Paste a URL in a DM to yourself. Slack renders cards in seconds.
- OpenGraph.xyz: Third-party scraper that shows you exactly what crawlers see.
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.