Skip to content

Django

A JOPS SPA can run in a Django project, served by Django like any other static files. Django stays the backend: the SPA has no models and fetches its data from the project’s own views and APIs.

SPA app Embedded in a page
What it is A Django app holding a complete JOPS SPA, with its own page at /<app>/ The SPA renders inside any Django template, next to the site’s header, menu and content
How npx jops-core init --django creates it, or set it up by hand The {% jops %} template tag

Both need the jops app: the {% jops %} template tag and the jops-core library (static/jops/lib/jops-core.min.js). Tested with Django 4.2 (Python 3.9) and 6.1 (Python 3.14).

  1. Install jops-core and run init in the folder with manage.py:

    Terminal window
    npm i jops-core
    npx jops-core init --django
  2. Pick a SPA app, 1 (Empty) or 2 (Hello World), and press Enter to name it jops_spa.

  3. Start Django and open http://127.0.0.1:8000/jops_spa/:

    Terminal window
    python manage.py runserver

Both options install the same jops app.

Terminal window
npm i jops-core
npx jops-core init --django

init writes the app into the project (jops/) and adds it to INSTALLED_APPS (see What init –django does).

Updating: run npm i jops-core@latest, then npx jops-core update, which refreshes jops/.

init adds the jops app in every case and asks whether to create a SPA app:

Choice What it creates
1 Empty JOPS SPA app: the basic skeleton
2 Hello World JOPS SPA app: an example app using every feature
3 None: only the jops app ({% jops %} and the library)

For 1 and 2 it asks for the app’s name (jops_spa by default, or --name <app>), then:

  • creates the app: <app>/static/<app>/ (the SPA’s files), <app>/templates/<app>/index.html (its page) and <app>/urls.py (one route);
  • adds "jops" and "<app>" to INSTALLED_APPS and path("<app>/", include("<app>.urls")) to the project’s urlpatterns.

When settings.py or urls.py don’t have the standard startproject layout (a settings package, a tuple), init changes nothing there and prints the lines to add.

  • Directoryjops/ the jops app
    • static/jops/lib/jops-core.min.js
    • templatetags/jops.py the {% jops %} tag
  • Directoryjops_spa/ the SPA app
    • Directorystatic/jops_spa/
      • Directorysrc/
        • App.js entry module
        • Directorylayout/ …
          • …
        • Directorycss/ …
          • …
      • Directoryassets/ …
        • …
    • templates/jops_spa/index.html
    • urls.py
  • manage.py

init stops without changing anything when the name is taken, when jops/ exists and is not the JOPS app, or when there is no manage.py. Run it again to add another SPA app; the existing jops/ is kept.

The SPA’s page sets the folder once with data-base, so every path inside the JOPS files is relative to the SPA’s folder. The same files work under /static/, another STATIC_URL, a CDN or hashed file names.

jops_spa/templates/jops_spa/index.html
{% load static %}
<script type="importmap" nonce="{{ request.csp_nonce }}">
{ "imports": { "jops-core": "{% static 'jops/lib/jops-core.min.js' %}" } }
</script>
<script type="module" src="{% static 'jops/lib/jops-core.min.js' %}"
data-app="{% static 'jops_spa/src/App.js' %}"
data-base="{% get_static_prefix %}jops_spa/"></script>
jops_spa/static/jops_spa/src/App.js
mainContainer.loadLayout("src/layout/mainlayout.html"); // not "/static/jops_spa/src/..."
  • In layouts the same: src="src/Features.js", layout="src/layout/header.html".
  • Use {% get_static_prefix %} for data-base, not {% static %}: with hashed file names (ManifestStaticFilesStorage, WhiteNoise) {% static %} only accepts files.
  • Images in layouts or built in code are loaded by the browser, not by JOPS: build their URLs from the module (new URL("../assets/logo.svg", import.meta.url).href) or reference them from the stylesheet.
  • nonce="{{ request.csp_nonce }}" lets the inline import map run under a django-csp nonce policy; without django-csp it renders empty.
  • More in The app’s base folder.

In an existing app or a new one (python manage.py startapp):

  1. Put the SPA’s files under the app’s static/<app>/. In an existing app, go one level deeper (static/<app>/spa/), so they don’t mix with its own files.
  2. Add a page like the one above under templates/<app>/.
  3. Add one route to it, e.g. path("spa/", TemplateView.as_view(template_name="<app>/spa.html")).
  4. Make sure the app is in INSTALLED_APPS.

Embed a SPA in any Django page: {% jops %}

Section titled “Embed a SPA in any Django page: {% jops %}”
shop/templates/shop/catalog.html
{% extends "base.html" %}
{% load static jops %}
{% block head %}
<link rel="stylesheet" href="{% static 'jops_spa/src/css/spa.css' %}">
{% endblock %}
{% block content %}
{% jops "jops_spa/src/App.js" base="jops_spa/" id="spa-root" class="spa" %}
{% endblock %}
Argument Description
app (first) Required. The entry module: a static path (resolved with {% static %}), or a root-based path or URL used as is
base The SPA’s folder as a static path ("jops_spa/"), joined to STATIC_URL. Paths inside the JOPS files are relative to it
id id of the app’s root <div> (default jops-root)
class class of the root <div>; use it to scope the app’s CSS
lib URL of jops-core.min.js (default: the jops app’s copy). Use one URL per page, or the browser loads two jops-core instances
  • The tag outputs the import map, the library in hosted mode, the root <div> and a small script that mounts the app (see Embedding).
  • One JOPS app per page. A second {% jops %} logs a warning and is not started.
  • CSP: with django-csp, the inline scripts get the request’s nonce (script-src with NONCE).

The root <div> carries the request’s CSRF token as data-csrf. Send it with requests that change data:

const token = document.getElementById("spa-root").dataset.csrf;
await fetch("/api/orders/", {
method: "POST",
headers: { "X-CSRFToken": token, "Content-Type": "application/json" },
body: JSON.stringify(order),
});

On a standalone SPA page, render the token into the template yourself (for example {% csrf_token %} or a <meta> tag) and read it from there.

  • Routing: Django owns the path (/jops_spa/), JOPS owns the hash (/jops_spa/#details?id=7), so one URL route per SPA is enough. Leaving the page is a full page load, which ends the app.
  • Backend: call your own views and APIs with fetch(). Requests are same-origin, so the session cookie is sent; writes need the CSRF token.
  • Production: collectstatic collects the jops app’s library and the SPA’s files like any other static files (WhiteNoise, Nginx, S3). Hashed file names work: Django hashes the files the page names (the library, App.js), and JOPS loads the others by their plain names, which collectstatic also keeps.
  • Import the library only as "jops-core" (the import map), never by its URL, or hashed file names give the page a second instance.
  • CDN STATIC_URL: the static host must send CORS headers (Access-Control-Allow-Origin), because the browser loads the library and the SPA’s modules from another domain.