Django: render JavaScript import maps in templates - Adam Johnson

Django: render JavaScript import maps in templates

There’s no map for friendship, but I know if there was, it would lead me straight to you.

JavaScript’s import statement lets module scripts import objects from other scripts. For example, you can define a script as a module in HTML:

<script type=module src="/static/js/index.js"></script>

Then that file can import from another, like:

import { BigBlueButton } from "/static/js/lib/buttons.js";

// ...

This syntax will work within the context of a Django project, but it’s not ideal. You typically want to render static file URLs with {% static %} rather than hardcode them like /static/js/lib/buttons.js. This way, if you’re using the popular Whitenoise package or Django’s built-in ManifestStaticFilesStorage, the rendered asset URLs will have cache-busting hashed filenames. But you can’t use {% static %} within JavaScript files (without templating them, which prevents you from using other JavaScript tooling on the files, such as Biome).

Django 4.2 added experimental import statement rewriting to ManifestStaticFilesStorage. This feature makes collectstatic modify import statements in collected JavaScript files to use hashed filenames. For example, if used on the above file, it might output:

import { BigBlueButton } from "/static/js/lib/buttons.decac99afbe8.js";

// ...

To activate it, subclass ManifestStaticFilesStorage and set the support_js_module_import_aggregation attribute to True (documentation). But this is marked experimental due to Ticket #34322, which I reported, showing that the changes could break code using imports in comments. Such comments are often used for TypeScript types, such as in htmx, so using this feature is not generally feasible (at least, yet).

Import maps are a new-ish JavaScript feature (Baseline 2023) that provides us with an alternative solution. Web pages may contain a single <script type=importmap> tag, which maps names to full module URLs. To define one, add the particular JSON structure within such a tag, before any module scripts that use it:

<script type=importmap>
  {
    "imports": {
      "buttons": "/static/js/lib/buttons.decac99afbe8.js"
    }
  }
</script>
<script type=module src="/static/js/index.js"></script>

This import map tells the browser that the bare module “buttons” can be loaded from the path /static/js/lib/buttons.decac99afbe8.js. The consuming JavaScript file index.js can then import the bare module like:

import { BigBlueButton } from 'buttons';

// ...

Currently, import maps are supported by 94% of global traffic. If you’re developing an internal project, that is probably good enough, since you can ask staff to upgrade if necessary. For public-facing sites, you may want to provide a fallback for the remaining 6%, for which the es-module-shims project can help.

Within Django, we can render such an import map using {% static %} to define the module URLs:

<script type=importmap>
  {
    "imports": {
      "buttons": "{% static 'js/lib/buttons.js' %}"
    }
  }
</script>
<script type=module src="{% static 'js/index.js' %}"></script>

To share the import map across multiple pages, define it in a base template, whether that’s for your whole site or some section.

By the way, because import maps are <script> tags, they are subject to CSP restrictions. If you’re using django-csp to set a strict CSP, use its nonce feature to allow the import map script:

<script type=importmap nonce="{{ request.csp_nonce }}">

Given that a page may only include one import map, you may need to include some modules that are only used on a few pages. That’s generally okay, as browsers only download modules as needed. The only cost will be unused entries.

But in some cases, such as when using the strategy pattern, different pages may need different entries. In this case, you need to step beyond this simple approach.

Extensible import maps with a template tag

Extending the import map from within Django’s template language would be difficult, given the vagaries of templating JSON. Instead, it’s best to step outside to a custom template tag.

Below is a tag definition that safely renders an import map from a given mapping of bare module names to paths. Pop it into a custom template tag module in an app’s templatetags directory, as described in Django’s custom template tag tutorial. For example, if you have an app called core, you could copy this code to core/templatetags/importmaps.py.

import json

from django.template import Library
from django.templatetags.static import static
from django.utils.html import _json_script_escapes, format_html
from django.utils.safestring import SafeString, mark_safe

register = Library()


@register.simple_tag
def importmap(names: dict[str, str]) -> SafeString:
    """
    Render a <script type=importmap> for the given mapping of bare module
    names to paths for {% static %}.

    https://adamj.eu/tech/2025/01/09/django-import-maps/
    """
    contents = {
        "imports": {name: static(value) for name, value in names.items()},
    }
    json_str = json.dumps(
        contents,
        separators=(",", ":"),
    ).translate(_json_script_escapes)
    return format_html(
        "<script type=importmap>{}</script>",
        mark_safe(json_str),
    )

Note:

Use this tag in your base template, before the point any module scripts will be rendered. Pass it a variable, like importmap_names:

{% load importmap from importmap %}
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
    {% importmap importmap_names %}
    {% block extrahead %}{% endblock extrahead %}
    <!-- ... -->
  </head>
  <body>
      <!-- ... -->
  </body>
</html>

Then, define that variable in your view. A base function or class can define a dictionary of always-included entries. Then sub-views can extend, or otherwise modify, that dictionary. For example, with function-based views using a base context function:

from django.shortcuts import render


def base_context(request):
    return {
        "importmap_names": {
            "buttons": "js/buttons.js",
        }
    }


def index(request):
    return render(request, "index.html", base_context(request))


def pies(request):
    context = base_context(request)
    context["importmap_names"]["crusts"] = "js/crusts.js"
    return render(request, "pies.html", context)

The corresponding templates don’t need mention of the import maps, given they extend the base template.

In this example, the index page will include an import map with just “buttons”:

<script type=importmap>
  {
    "imports": {
      "buttons": "/static/js/buttons.js"
    }
  }
</script>

(But with whitespace stripped.)

While the pies page will include both “buttons” and “crusts”:

<script type=importmap>
  {
    "imports": {
      "buttons": "/static/js/buttons.js",
      "crusts": "/static/js/crusts.js"
    }
  }
</script>

Fin

Shouts to:

Although this post covers a different approach to the above packages, they helped me understand the problem space.

May you be a happy, mappy importer!

—Adam


Read my book Boost Your Django DX, freshly updated in November 2024.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,