Building Better OG Images with Playwright and Pillow

If you have ever pasted one of your own links into Bluesky or Slack and winced, this post is for you. My Open Graph preview images were technically fine: 1200x630, correct meta tags, generated on the fly with Pillow. They were also boring. Flat background, two accent bars, gray title text. Every link looked like a default template because it was one.

I rebuilt the whole pipeline in an afternoon with Claude Code. The new previews show the actual page inside a simulated browser window, sitting on a brand-colored gradient. Here is how it works, including the gotchas.

Requirements

  • Every page gets an image. Posts, solvers, games, category pages, even 404-adjacent oddities. No bare og:image tags, ever.
  • Show the real page. A screenshot beats a stock layout. People should see what they are about to click.
  • On-brand. Site palette (cyan #0697AE family), logo, wordmark.
  • No headless browser in production. The site runs on a small Elastic Beanstalk instance. Chromium does not get to live there.
  • A fallback that does not embarrass me. New pages exist before screenshots do. The runtime fallback has to look intentional.
  • No title text in the image. Twitter, Bluesky, Slack, and friends all render the og:title right next to the card. Painting it into the image just says everything twice. The screenshot is the hero.

The architecture

Three pieces:

  • og_image.py: a Pillow module that draws the branded frame (gradient, logo, browser chrome) around either a screenshot or a skeleton page.
  • generate_og_screenshots.py: a local-only Playwright script that walks the sitemap, screenshots every page, and composites it into the frame. Output lands in static/og_gen/ and ships with the deploy.
  • A Flask route at /og/<id>.png that serves the pre-generated file if it exists, otherwise generates the fallback at request time.

Playwright is deliberately not in requirements.txt. It lives in the dev venv only. The server never runs a browser; it just serves PNGs.

Drawing the browser window with Pillow

The frame is a four-corner gradient, the site logo, and a fake browser window with traffic lights and a URL pill. The window intentionally bleeds off the bottom edge of the canvas, which makes the crop feel deliberate instead of cramped:

WIDTH, HEIGHT = 1200, 630
PADDING = 64
CHROME_H = 44

def _draw_browser_chrome(img, draw, url_text, window_top):
    # Drop shadow, blurred
    shadow = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
    ImageDraw.Draw(shadow).rounded_rectangle(
        [(PADDING, window_top + 10), (WIDTH - PADDING, HEIGHT + 60)],
        radius=14, fill=(20, 60, 70, 90))
    img.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(16)))

    # Window card, bottom edge off-canvas
    draw.rounded_rectangle(
        [(PADDING, window_top), (WIDTH - PADDING, HEIGHT + 60)],
        radius=14, fill="#ffffff")

    # Traffic lights
    ty = window_top + CHROME_H // 2
    for i, color in enumerate(("#ff5f57", "#febc2e", "#28c840")):
        cx = PADDING + 26 + i * 22
        draw.ellipse([(cx - 6, ty - 6), (cx + 6, ty + 6)], fill=color)

    return window_top + CHROME_H + 1

The screenshot gets scaled to the window width and cropped to whatever is visible:

def _paste_screenshot_body(img, body_top, screenshot):
    shot = screenshot.convert("RGB")
    scale = WINDOW_W / shot.width
    shot = shot.resize((WINDOW_W, int(shot.height * scale)), Image.LANCZOS)
    shot = shot.crop((0, 0, WINDOW_W, min(shot.height, HEIGHT - body_top)))
    img.paste(shot, (PADDING, body_top))

Harvesting screenshots with Playwright

The generator does not keep its own list of pages. It reads the sitemap, and for each URL it scrapes the page's own meta tags to learn what the image should be called. If a page advertises og:image = /og/wordle-solver.png, that is the filename. Pages with a custom photo get skipped automatically:

OG_ID_RE = re.compile(r"/og/([A-Za-z0-9_-]+)\.png")

page.goto(url, wait_until="load", timeout=30000)
page.wait_for_timeout(1500)  # let fonts and lazy content settle

m = OG_ID_RE.search(meta["image"] or "")
if not m:
    continue  # custom og:image, leave it alone
og_id = m.group(1)

shot = Image.open(io.BytesIO(page.screenshot()))
img = generate_og_image(title, screenshot=shot,
                        url="nerdymark.com" + path)
img.save(OUT_DIR / f"{og_id}.png", optimize=True)

Screenshots happen at a 1280x800 viewport with device_scale_factor=2, so the downscale into the frame stays crisp. The full run covers about 110 pages in eight minutes.

Serving with a graceful fallback

The route prefers the pre-generated composite and falls back to runtime Pillow generation. The fallback keeps the big title (there is no screenshot to speak for the page) and fills the browser window with a tasteful skeleton layout instead:

@application.route("/og/<identifier>.png")
def og_image_route(identifier):
    if re.fullmatch(r"[A-Za-z0-9_-]+", identifier):
        pregen = os.path.join(application.root_path,
                              "static", "og_gen", f"{identifier}.png")
        if os.path.isfile(pregen):
            resp = make_response(send_from_directory(
                "static/og_gen", f"{identifier}.png",
                mimetype="image/png"))
            resp.headers["Cache-Control"] = "public, max-age=86400"
            return resp
    # ... fall through to runtime Pillow generation

The bug that ate my homepage

One real gotcha. A few standalone templates (old game pages, a demo scene) had hardcoded og:image = /og/default.png instead of using their derived page id. Since the generator names files based on what each page advertises, the /resume/demo page happily saved its screenshot as default.png and replaced my homepage preview with a retro demoscene starfield.

Two fixes: the templates now use their per-page id, and the generator refuses to write the same og_id twice in one run (first page wins, later collisions get logged and skipped). If you build something like this, dedupe on the output filename. Your homepage will thank you.

Things worth knowing

  • Social platforms cache aggressively. Links shared before the change keep their old card until the platform re-scrapes. Facebook's Sharing Debugger and fresh paste-ins force it.
  • Weight is fine. 109 PNGs came to about 7 MB. They ride along in the deploy archive.
  • The fallback earns its keep. Any brand-new page instantly gets a branded card with its title and a skeleton browser window, then upgrades to a real screenshot on the next generator run.

Total cost: one afternoon, zero new production dependencies, and my links finally look like someone cared. Try pasting the Wordle solver somewhere and see for yourself.

More in Technology