Skip to main content

html2img — HTML to image API, rendered in real Chrome

Open Graph Images for Django

PyPI Version Python Versions Django Versions License

Automatic Open Graph (social share) images for your Django models, rendered by the HTML to Image API in real Chrome. You design the card as an ordinary Django template with full CSS control, and the package renders it against each object, sends the HTML to the API, and stores the returned image URL on the model.

Because the design is a template in your project rather than a fixed layout, flexbox, grid, custom properties, web fonts and anything else you can write in CSS behave exactly as they do in the browser. Built on the official html2img Python client.

⚠️ A free html2img API key is required. This package generates your Open Graph images through the HTML to Image API, so it needs a key to render anything. Creating an account is free and includes 50 credits, with no card needed to get started. Images rendered on the free tier are hosted for seven days; on any paid plan they are hosted permanently, including everything you rendered before upgrading.

Get your free API key at app.html2img.com

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.

Contents

Requirements

  • Python 3.10 or newer
  • Django 4.2 or newer (4.2 LTS, 5.x, 6.x)
  • A free HTML to Image API key; every account starts with 50 free credits

Installation

pip install html2img-django

Add the app to INSTALLED_APPS:

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

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

HTML2IMG_API_KEY=your-api-key

See the authentication docs for issuing and rotating keys.

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 it 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 image URL lands on the object. Check it worked:

python manage.py html2img_test

That is the whole setup. Everything below is customisation.

Designing the card

The bundled default template (html2img_django/default.html) is a complete 1200×630 card, and it is deliberately plain so 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 HTML to Image 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 a single 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/ — the card with representative sample data.
  • /og-images/preview/blog.Post/1/ — the card for 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 the design looks right, save the object (or use the admin's Regenerate Open Graph images action) to render it for real.

Local development and public URLs

Renders happen on the HTML to Image servers in real Chrome, so every URL in your template — web fonts, images, stylesheets — must be reachable from the public internet. In production your media and static URLs already are, so the rendered image matches the browser preview.

On a local development site this is not the case: an image served from localhost:8000 or *.ddev.site is invisible to the API and shows as missing in the rendered PNG, even though your browser preview shows it. The fix is to reference publicly hosted assets, or to expose your dev site with a tunnel (cloudflared tunnel --url ..., ddev share, ngrok http 8000) and build absolute URLs against it while you test. Web fonts loaded from a public CDN such as Google Fonts always work, because they are already public.

Configuration

Everything lives in one HTML2IMG dict in your settings. 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. 1200×630 is the standard OG size.
DPI 2 Device pixel ratio, 1 to 4. 2 is retina.
FORMAT "png" "png" or "pdf". See the HTML to PDF API.
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 html2img import Html2img
from html2img_django.client import set_client


class BlogConfig(AppConfig):
    def ready(self):
        set_client(Html2img(transport=my_retrying_transport))

See custom transports in the client's README.

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,  # image size
    height=630,
    dpi=2,  # 2 for retina
    format="png",  # or "pdf"
    storage="cdn",  # or "media"
    context=lambda post: {...},  # extra template context
    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. 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 — 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 generate an image for this object.

Outputting the tags

Standalone

{% load og_image %}

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

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

{% og_image_url object %} 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" (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 (up to a few seconds).
  • "off" — nothing happens automatically. Use this when you have a real task queue and want to drive it yourself.

With Celery, RQ or 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
@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 — whether a credit was actually spent — use generate_result(), which returns the same work with a verdict attached:

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. Images render permanently 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.

Bulk regeneration

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 (this 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 line 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 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 opted out. The preview panel needs the package's URLs to be included.

Generating something other than an OG image

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

from html2img import Html2img

client = Html2img()

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

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

# A ready-made template, no markup of your own — https://html2img.com/templates
client.template("invoice-image", {"number": 1042, "amount": "£240.00"})

Screenshots are a natural fit for link previews, article thumbnails and monitoring; PDFs for invoices, tickets and reports, where text stays selectable and long content paginates automatically.

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 documented error codes.

Verifying your setup

python manage.py html2img_test

It prints the resolved settings, the registered models, whether your card template can be found, and then renders a small test image and reports your remaining credits. The render uses one credit; pass --no-render to check the configuration without spending one.

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 set_client, reset_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()

Other integrations

The same API has official packages and worked guides for Python, Laravel, PHP, Statamic, WordPress, JavaScript and Node.js, React, Vue and Ruby on Rails.

Development

python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'

pytest              # tests, no network and no credits spent
ruff check .        # lint
ruff format .       # format
mypy                # static analysis

A ready-made Django project for exercising the package by hand lives in html2img-django-test. Publishing to PyPI is covered in PUBLISHING.md.

Links

HTML to Image API · Screenshot API · HTML to PDF API · Documentation · Templates · Tools · Features · Pricing · Python client

Licence

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

html2img_django-1.0.1.tar.gz (35.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

html2img_django-1.0.1-py3-none-any.whl (35.6 kB view details)

Uploaded Python 3

File details

Details for the file html2img_django-1.0.1.tar.gz.

File metadata

  • Download URL: html2img_django-1.0.1.tar.gz
  • Upload date:
  • Size: 35.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for html2img_django-1.0.1.tar.gz
Algorithm Hash digest
SHA256 1bcc9b1d81acc51f29945356f8089d40a68011bfa4a7abb9039270d834518bd6
MD5 e86158792b2f4bdf0cf7672d8b725a28
BLAKE2b-256 c595b7d9122848015c9b583e2bc3a79a407c9982b187b9613741f0b6401dd39a

See more details on using hashes here.

Provenance

The following attestation bundles were made for html2img_django-1.0.1.tar.gz:

Publisher: publish.yml on html2img/html2img-django

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file html2img_django-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: html2img_django-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 35.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for html2img_django-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ac1ab388b5ff8473ec777c8e2112bf211802f5d0c8345c0aee3b44d622d7957a
MD5 d2eeda015ee950c9104bb98d2dea1216
BLAKE2b-256 2fa4b0770088462a0e4273cc3a1d39da4c9187afed970eac2bb4832cb27d7f34

See more details on using hashes here.

Provenance

The following attestation bundles were made for html2img_django-1.0.1-py3-none-any.whl:

Publisher: publish.yml on html2img/html2img-django

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page