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).
Quick start
Section titled “Quick start”-
Install jops-core and run
initin the folder withmanage.py:Terminal window npm i jops-corenpx jops-core init --django -
Pick a SPA app, 1 (Empty) or 2 (Hello World), and press Enter to name it
jops_spa. -
Start Django and open
http://127.0.0.1:8000/jops_spa/:Terminal window python manage.py runserver
Install the jops app
Section titled “Install the jops app”Both options install the same jops app.
npm i jops-corenpx jops-core init --djangoinit 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/.
pip install https://github.com/uidb-dev/jops-core/releases/latest/download/django-jops.tar.gzThen add "jops" to INSTALLED_APPS. The app is installed with your other Python packages, and collectstatic picks up its library as usual. Set up a SPA app by hand (see Setting it up by hand) or embed one with {% jops %}.
Updating: pip install --force-reinstall <the same URL>. The latest URL does not change, so pip does not see a new version by itself.
Pinning a version (also in requirements.txt):
https://github.com/uidb-dev/jops-core/releases/download/v1.1.11/django-jops.tar.gzWhat init --django does
Section titled “What init --django does”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>"toINSTALLED_APPSandpath("<app>/", include("<app>.urls"))to the project’surlpatterns.
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.
Paths: the SPA’s folder is its base
Section titled “Paths: the SPA’s folder is its base”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.
{% 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>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 %}fordata-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.
Setting it up by hand
Section titled “Setting it up by hand”In an existing app or a new one (python manage.py startapp):
- 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. - Add a page like the one above under
templates/<app>/. - Add one route to it, e.g.
path("spa/", TemplateView.as_view(template_name="<app>/spa.html")). - 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 %}”{% 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-srcwithNONCE).
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.
Conventions
Section titled “Conventions”- 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:
collectstaticcollects thejopsapp’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, whichcollectstaticalso 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.