Official package

HTML to Image API for Django

The official Django package turns an ordinary Django template into an Open Graph image for every object you register. Saves render the card off the request cycle, unchanged inputs are skipped so routine saves cost nothing, and the resulting URL lands on the model.

pip install html2img-django

Requires: Python 3.10 or newer, Django 4.2 or newer. Every account starts with 50 free credits, no card needed.

See also Python WordPress Statamic

Most Open Graph image tooling asks you to learn a card builder. This package asks you to write a Django template. You design the card with the templating language you already use, register the models that should have one, and every save renders the card in real Chrome and stores the resulting URL on the object.

The package is built on the official Python client, which is installed as a dependency. For anything other than Open Graph images (screenshots, PDFs, named templates) reach for that client directly; the last section shows how.

What it does

  • Renders a developer-authored Django template into an Open Graph image when an object is saved, off the request cycle, and stores the i.html2img.com URL on the model.
  • Resolves settings through a cascade: project defaults, then per-model registration options, then per-object overrides.
  • Skips the render when the inputs are unchanged, so routine saves spend no credits.
  • Outputs the social tags itself, or hands the URL to the SEO package you already use.
  • Ships a staff-only live preview, an admin panel, a regenerate action and a bulk management command.

Requirements

RequirementVersion
Python3.10 or newer
Django4.2 LTS, 5.x or 6.x
html2img-clientInstalled automatically as a dependency
API keyFree, from your dashboard
An API key is required

The package generates images through the HTML to Image API, so it needs a key to render anything. Accounts are free and start with 50 credits, with no card needed. Free-tier renders are hosted for seven days; on any paid plan they are hosted permanently, including everything rendered before upgrading.

Installation

pip install html2img-django

Add the app:

INSTALLED_APPS = [
    # ...
    "html2img_django",
]

Put the key in the environment. This is the canonical source:

HTML2IMG_API_KEY=your-api-key

Issuing and rotating keys is covered in the authentication docs.

Quick start

1. Add the mixin to a model and migrate. It contributes the fields the pipeline needs, plus the editor-facing overrides:

from django.db import models
from html2img_django import OpenGraphImageMixin


class Post(OpenGraphImageMixin, models.Model):
    title = models.CharField(max_length=200)
    excerpt = models.TextField(blank=True)
    published_at = models.DateTimeField(null=True, blank=True)
python manage.py makemigrations && python manage.py migrate

2. Register the model. Create an og_images.py module in the app. It is imported automatically at startup, the same way admin.py is:

# blog/og_images.py
from html2img_django import og_images

from .models import Post

og_images.register(Post)

3. Output the tags in your base template:

{% load og_image %}
<head>
    {% og_image_meta object %}
</head>

4. Add the preview routes. Optional, but this is how you design the card:

# urls.py
urlpatterns = [
    path("og-images/", include("html2img_django.urls")),
]

5. Save a post. The card renders in the background and the URL lands on the object. Confirm it worked:

python manage.py html2img_test

Everything below is customisation.

Designing the card

The bundled default (html2img_django/default.html) is a complete 1200x630 card, and it is deliberately plain so that you replace it. Copy it into your project and point the settings at your copy:

HTML2IMG = {
    "TEMPLATE": "og/post.html",
}

Or per model, which is the usual case once you have more than one content type:

og_images.register(Post, template="og/post.html")
og_images.register(Product, template="og/product.html", height=800)

The template is rendered to a string and posted to the API, so anything a browser can render works: web fonts from a CDN, gradients, object-fit, -webkit-line-clamp for truncation, SVG, even inline JavaScript.

Template context

Every card is rendered with:

VariableWhat it is
objectThe model instance. Also available under its model name, so a Post is post.
og_headlineThe og_image_headline override, falling back to title, then str(obj).
og_subtitleThe og_image_subtitle override, if set.
site_nameHTML2IMG["SITE_NAME"], or the current Site name.
site_logoHTML2IMG["SITE_LOGO"], an absolute URL.

Add your own by overriding og_image_context() on the model:

class Post(OpenGraphImageMixin, models.Model):
    def og_image_context(self):
        return {
            "author": self.author.get_full_name(),
            "date": self.published_at,
            "image": self.cover.url if self.cover else "",
        }

or with a context callable at registration, which keeps the model clean:

og_images.register(
    Post,
    template="og/post.html",
    context=lambda post: {"reading_time": post.reading_time()},
)

Guard optional fields with {% if %} so one template can serve several models:

{% if author %}<span class="author">{{ author }}</span>{% endif %}
{% if image %}<img src="{{ image }}" alt="">{% endif %}

The preview loop

Design in the browser. The package ships a preview route that renders your template at the exact configured dimensions, with no API key required and no credits spent, because the browser renders the same HTML the API does:

  • /og-images/preview/ for the card with representative sample data.
  • /og-images/preview/blog.Post/1/ for the card of a real object.

Both are staff-only. The admin also embeds the live preview next to the last render, which is the parity check between what you designed and what the API produced. Once it looks right, save the object (or use the admin’s Regenerate Open Graph images action) to render it for real.

Local media is invisible to the renderer

Renders happen on our servers in real Chrome, so every URL in your template must be reachable from the public internet. In production your media and static URLs already are. On a development site an image served from localhost:8000 or *.ddev.site shows as missing in the rendered PNG even though the browser preview looks right. Reference publicly hosted assets, or expose the site with a tunnel (cloudflared tunnel --url ..., ddev share, ngrok http 8000) and build absolute URLs against it while you test. Google Fonts always work.

Configuration

Everything lives in one HTML2IMG dict. Anything you leave out uses the default:

HTML2IMG = {
    "API_KEY": None,  # falls back to $HTML2IMG_API_KEY
    "TEMPLATE": "html2img_django/default.html",
    "WIDTH": 1200,
    "HEIGHT": 630,
    "DPI": 2,
    "FORMAT": "png",
    "STORAGE": "cdn",  # or "media"
    "MEDIA_PATH": "og-images/{app_label}/{model_name}/{pk}.{extension}",
    "SITE_NAME": None,  # falls back to the Sites framework
    "SITE_LOGO": None,
    "DEFAULT_IMAGE": None,  # fallback when an object has no image
    "ON_SAVE": "thread",  # "thread", "sync" or "off"
    "ENABLED": True,
    "TIMEOUT": 35.0,
    "BASE_URL": None,  # only for private deployments
}
KeyDefaultPurpose
API_KEY$HTML2IMG_API_KEYSent as the X-API-Key header. Keep it out of version control.
TEMPLATEthe bundled defaultThe template rendered into the image.
WIDTH / HEIGHT1200 / 630Image size in CSS pixels. 1200x630 is the standard OG size.
DPI2Device pixel ratio, 1 to 4. 2 is retina.
FORMAT"png""png" or "pdf".
STORAGE"cdn""cdn" keeps the CDN URL; "media" downloads into Django storage.
MEDIA_PATHsee abovePath template for "media" storage.
SITE_NAMEthe current SitePassed to every card template.
SITE_LOGOnoneAbsolute URL of a logo, passed to every card template.
DEFAULT_IMAGEnoneUsed by the tags when an object has no image of its own.
ON_SAVE"thread"How a save is handled; see when images are generated.
ENABLEDTrueMaster switch. Set False in tests and local development.
TIMEOUT35.0Request timeout in seconds.

An unknown key raises ImproperlyConfigured at startup rather than being silently ignored, so a typo shows up immediately.

A custom API client

All requests go through one client, so you can supply your own for retry middleware, a proxy, or request logging:

# blog/apps.py
from django.apps import AppConfig
from html2img import Html2img
from html2img_django.client import set_client


class BlogConfig(AppConfig):
    name = "blog"

    def ready(self):
        set_client(Html2img(transport=my_retrying_transport))

The transport contract is documented on the Python integration page.

Registering models

og_images.register() takes the model and any per-model overrides:

og_images.register(
    Post,
    template="og/post.html",                               # the card design
    width=1200,
    height=630,
    dpi=2,
    format="png",                                          # or "pdf"
    storage="cdn",                                         # or "media"
    media_path="social/{app_label}/{pk}.{extension}",
    context=lambda post: {"reading_time": post.reading_time()},
    queryset=lambda: Post.objects.filter(published=True),  # what bulk regeneration walks
)

It also works as a decorator:

@og_images.register(template="og/product.html")
class Product(OpenGraphImageMixin, models.Model): ...

Registrations belong in an og_images.py module in any installed app; they are imported for you at startup. An unknown option, or registering a model that does not use OpenGraphImageMixin, raises ImproperlyConfigured with an explanation rather than failing later at render time.

Per-object overrides

The mixin gives editors three escape hatches, all optional:

  • og_image_headline and og_image_subtitle override the text on the card without touching the title.

  • og_image_custom is an image URL that bypasses generation entirely. Override get_og_custom_image() to point it at an uploaded file instead:

    def get_og_custom_image(self):
        return self.social_image.url if self.social_image else ""
  • og_image_disabled never generates an image for this object.

Template tags

Standalone

{% load og_image %}

<head>
    <title>{{ object.title }}</title>
    {% og_image_meta object %}
</head>

og_image_meta writes og:image, og:image:width, og:image:height, og:image:type, og:image:alt, twitter:card and twitter:image, resolving the cascade: the custom image, then the generated image, then DEFAULT_IMAGE. If there is no image at all it writes nothing rather than empty tags.

og_image_url returns just the URL, for feeds, JSON-LD, emails or an img tag:

<meta property="og:image" content="{% og_image_url object %}">

With an existing SEO package

If you already run django-meta, wagtail-metadata or your own meta layer, skip the tags and feed it the URL. get_og_image_url() on the model resolves the same cascade:

class Post(OpenGraphImageMixin, models.Model):
    def as_meta(self, request=None):
        meta = super().as_meta(request)
        meta.image = self.get_og_image_url()

        return meta

When images are generated

HTML2IMG["ON_SAVE"] decides what a save does. In every mode the work is deferred to transaction.on_commit, so nothing renders against a state that then rolls back:

  • "thread" (the default) renders in a background thread, so the save returns immediately. Good for the admin and for small to medium sites; the thread gets its own database connection and closes it when done.
  • "sync" renders inline. Simple and predictable, but the save waits for the API, which is a few seconds.
  • "off" does nothing automatically. Use this when you have a real task queue and want to drive it yourself.

A render is skipped when the card’s inputs are unchanged, so routine saves cost nothing. That fingerprint is what keeps a busy editorial workflow from spending a credit every time somebody fixes a typo in the body copy.

Celery, RQ and Huey

For anything busy, set ON_SAVE to "off" and dispatch from your own task, which gives you retries, rate limiting and visibility:

# blog/tasks.py
from celery import shared_task
from django.apps import apps
from html2img_django import generate


@shared_task(bind=True, max_retries=3)
def generate_og_image(self, label: str, pk: int) -> None:
    model = apps.get_model(label)
    obj = model.objects.filter(pk=pk).first()

    if obj is not None:
        generate(obj)
# blog/signals.py
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Post
from .tasks import generate_og_image


@receiver(post_save, sender=Post)
def queue_og_image(sender, instance, **kwargs):
    transaction.on_commit(lambda: generate_og_image.delay(sender._meta.label, instance.pk))

generate(obj, force=False) is the single entry point: it resolves the settings, renders the card, calls the API and stores the result. It returns the stored URL, or None when nothing was rendered.

When you need to know what happened, and in particular whether a credit was actually spent, use generate_result():

from html2img_django import generate_result

result = generate_result(post)

result.url       # str | None
result.rendered  # True only when the API was called and returned an image
result.reused    # True when the card was unchanged, so nothing was rendered
result.ok        # True when the object ended up with an image, either way
result.reason    # "unchanged", "opted-out", "disabled", "error", "no-url", "unsaved"

Storage modes

  • "cdn" (default) stores the i.html2img.com URL on the object. Nothing to serve, and the CDN handles the traffic. Renders are permanent on paid plans.
  • "media" downloads the render into your Django storage (default_storage, so S3 and friends work through django-storages) and stores that URL instead. Use it when you would rather not depend on a third-party URL in your markup.
HTML2IMG = {
    "STORAGE": "media",
    "MEDIA_PATH": "social/{app_label}/{model_name}/{pk}.{extension}",
}

If the download or the write fails, the CDN URL is kept, so a storage problem never loses a render.

Free-tier renders expire after seven days

On the free tier, "media" storage is the safer default: the image lives in your own storage and keeps working after the CDN copy expires. On any paid plan CDN renders are permanent, and upgrading makes earlier renders permanent too.

The admin

Mix OpenGraphImageAdminMixin into a ModelAdmin for a live preview, the last render, and a regenerate action:

from django.contrib import admin
from html2img_django.admin import OpenGraphImageAdminMixin

from .models import Post


@admin.register(Post)
class PostAdmin(OpenGraphImageAdminMixin, admin.ModelAdmin):
    list_display = ("title", "published_at", "og_image_status")
    readonly_fields = ("og_image_preview",)

og_image_status reports whether an object has a generated image, a custom one, or has opted out. The preview panel needs the package’s URLs to be included. The mixin also adds a Regenerate Open Graph images bulk action to the changelist.

Management commands

After changing a card template, regenerate across your registered models:

python manage.py generate_og_images
python manage.py generate_og_images --model blog.Post --force
python manage.py generate_og_images --dry-run
python manage.py generate_og_images --model blog.Post --limit 50
  • --force ignores the input fingerprint and re-renders everything, which spends a credit per object.
  • Without --force, objects whose card has not changed are reported as unchanged and cost nothing.
  • --dry-run lists what would be rendered without calling the API.

The summary separates the two, so you always know what a run cost:

blog.Post: 3 object(s)
  How real Chrome rendering changes social images: https://i.html2img.com/abc123.png
  Designing a card that survives a very long title: unchanged, kept https://i.html2img.com/def456.png
  Why the fingerprint matters: skipped (opted out or has a custom image)
Done. 1 rendered, 1 unchanged, 1 skipped, 0 failed.

The health check reports the resolved settings, the registered models and whether your card template can be found, then renders a small test image:

python manage.py html2img_test
python manage.py html2img_test --no-render   # configuration only, no credit spent

Errors and logging

Nothing in the pipeline raises into your request cycle. A failed render is logged and the object keeps whatever image it had, so a hiccup at the API never breaks a save or a page. Everything is logged under the html2img_django logger:

LOGGING = {
    "version": 1,
    "loggers": {
        "html2img_django": {"handlers": ["console"], "level": "INFO"},
    },
}

At DEBUG you also see why an object was skipped (unchanged inputs, opted out, custom image, rendering disabled). The messages carry the API’s code and HTTP status, which map to the codes listed in the getting started guide. To distinguish “nothing needed doing” from “something failed” in your own code, use generate_result() and check result.reason.

Testing your project

Switch rendering off so your test suite never calls the API:

# settings/test.py
HTML2IMG = {"ENABLED": False}

With ENABLED set to False, saves do nothing and generate() returns None. When you do want to assert on the pipeline, inject a client backed by a fake transport:

from html2img import Html2img
from html2img_django.client import reset_client, set_client


def fake_transport(*, method, url, headers, body, timeout):
    return 200, b'{"success": true, "url": "https://i.html2img.com/test.png"}'


set_client(Html2img("test-key", transport=fake_transport))
# ... exercise your code ...
reset_client()

Anything other than OG images

The package is deliberately focused on Open Graph images. The same account and key drive the whole API through the Python client, which is already installed:

from django.template.loader import render_to_string
from html2img import Html2img

client = Html2img()

# A screenshot of a live URL
client.screenshot("https://example.com/pricing", fullpage=True, dpi=2)

# An invoice as a vector PDF
client.html(render_to_string("invoices/show.html", {"invoice": invoice}), format="pdf")

# A ready-made template, no markup of your own
client.template("invoice-image", {"invoice_number": "INV-1042", "total": "£240.00"})

Screenshots suit link previews, article thumbnails and monitoring; PDFs suit invoices, tickets and reports, where text stays selectable and long content paginates automatically. The Python page covers the client in full, including the async client, saving and downloading, error handling and the command line tool.

Troubleshooting

ImproperlyConfigured: Unknown HTML2IMG settings. A typo in the settings dict. The message lists the valid keys; the package refuses to start rather than silently ignoring a misspelled TEMPALTE.

No image appears after saving. Check three things in order: ENABLED is not False for the environment you are in, the model is registered in an og_images.py inside an installed app, and python manage.py html2img_test succeeds. The management command reports all three.

The card renders but images inside it are missing. The renderer fetches media over the public internet, so a localhost or *.ddev.site URL is invisible to it. Use absolute public URLs in production, and a tunnel while developing.

Registering the model raises ImproperlyConfigured. The model needs OpenGraphImageMixin in its bases, and a migration after adding it. The mixin contributes real fields, so makemigrations is not optional.

Renders spend a credit on every save. They should not: the fingerprint skips unchanged cards. If they do, something in the card’s inputs is changing every time (a timestamp in og_image_context(), for instance). Use generate_result() and check result.reason to see which inputs the pipeline thinks changed.

Saves are slow in the admin. ON_SAVE is probably "sync". Switch it back to "thread", or to "off" with a Celery task if you want retries and visibility.

A thread-mode render fails with a database error under a test runner. Thread mode opens its own connection, which does not see an open test transaction. Set ENABLED to False in test settings, or use "sync" there.

FAQ

Do I need the Python client as well? It is installed automatically as a dependency. Import html2img directly whenever you want a screenshot, a PDF or a named template; see anything other than OG images.

Which Django versions are supported? Django 4.2 LTS and newer, including 5.x and 6.x, on Python 3.10 or newer.

Can I use this with Wagtail? Yes, for any model you can add the mixin to. Register it as usual and feed get_og_image_url() into whichever meta layer your site uses.

Does it work with custom user-uploaded social images? Yes. og_image_custom takes a URL and wins over the generated image, and overriding get_og_custom_image() lets you point that at an existing ImageField rather than asking editors to paste a URL.

How much does a busy site cost to run? One credit per render, and unchanged cards are not re-rendered, so the running cost is roughly one credit per meaningful edit rather than one per save. The management command’s --dry-run tells you what a bulk regeneration would cost before you run it.

Can the card be a PDF? FORMAT accepts "pdf", though it is an odd thing to put in an og:image tag. It is more useful when you register a model whose “card” is really a document.

How do I check my balance without spending a credit? Call GET /api/me, or run python manage.py html2img_test --no-render.

Start rendering from Django

50 free credits, no card required. One credit renders one image or one PDF.