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.
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.comURL 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
| Requirement | Version |
|---|---|
| Python | 3.10 or newer |
| Django | 4.2 LTS, 5.x or 6.x |
html2img-client | Installed automatically as a dependency |
| API key | Free, from your dashboard |
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:
| Variable | What it is |
|---|---|
object | The model instance. Also available under its model name, so a Post is post. |
og_headline | The og_image_headline override, falling back to title, then str(obj). |
og_subtitle | The og_image_subtitle override, if set. |
site_name | HTML2IMG["SITE_NAME"], or the current Site name. |
site_logo | HTML2IMG["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.
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
}
| Key | Default | Purpose |
|---|---|---|
API_KEY | $HTML2IMG_API_KEY | Sent as the X-API-Key header. Keep it out of version control. |
TEMPLATE | the bundled default | The template rendered into the image. |
WIDTH / HEIGHT | 1200 / 630 | Image size in CSS pixels. 1200x630 is the standard OG size. |
DPI | 2 | Device 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_PATH | see above | Path template for "media" storage. |
SITE_NAME | the current Site | Passed to every card template. |
SITE_LOGO | none | Absolute URL of a logo, passed to every card template. |
DEFAULT_IMAGE | none | Used 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. |
ENABLED | True | Master switch. Set False in tests and local development. |
TIMEOUT | 35.0 | Request 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_headlineandog_image_subtitleoverride the text on the card without touching the title. -
og_image_customis an image URL that bypasses generation entirely. Overrideget_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_disablednever 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 thei.html2img.comURL 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.
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
--forceignores 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-runlists 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.
Related integrations
The same API and the same key across every stack. These are the neighbours of the Django integration.
Start rendering from Django
50 free credits, no card required. One credit renders one image or one PDF.