SPA shell¶
Embed a client framework (Svelte in this example) inside djcrud’s navigation
shell while keeping sidebar navigation server-rendered in HTML. Example app:
spa_example (/spa/).
Complete DRF API first if you want the demo to call /api/product/ from
the browser. The SPA chapter itself only needs djcrud.site — no ViewSet
registration required.
SPA template convention¶
SPAView uses djcrud/base_spa.html and sets
unpoly_target = 'body' for you. Custom SPA shells should follow the same
*base_spa.html naming convention. Menu links on SPA pages always use
up-follow="false" so every hop reloads the full document and exits the SPA
shell cleanly.
View and registration¶
Subclass SPAView and append it to
djcrud.site — no model router required. Extend class Media to append
your ES module paths as Script entries with
type="module".
urlpath defaults to the view codename (spa/ for
SpaView):
from django.forms.widgets import Script
import djcrud
from djcrud.static import vite_asset
from djcrud.views.spa import SPAView
class SpaView(SPAView):
title = "SPA demo"
icon = "grid"
mount_element = '<div id="app"></div>'
class Media(SPAView.Media):
js = SPAView.Media.js + (
Script(vite_asset("spa_example/js/app.js"), type="module"),
)
djcrud.site.routes.append(SpaView)
Mounting your app¶
There is no index.html in the frontend package. Django renders
djcrud/base_spa.html (sidebar, CSRF, {{ view.media }}); your
bootstrap HTML and client bundle fill in the rest.
Put the mount node on your view — same pattern as icon or tags (see
Philosophy):
mount_element = '<div id="app"></div>'
djcrud/base_spa.html renders it inside [up-main] via
{{ view.mount_element|safe }}. Your ES module must query the same element
and attach your framework:
import { mount } from 'svelte'
import App from './App.svelte'
import '../../../../djcrud_bulma/static/djcrud_bulma/js/hamburger.js'
const target = document.getElementById('app')
if (target) {
mount(App, { target })
}
After npm run build, Vite writes a content-hashed bundle (e.g.
app.9f3c2a1b.js) together with .vite/manifest.json under
spa_example/static/spa_example/js/. The djcrud.py entry uses
vite_asset() so the logical name stays stable while the
actual served file name contains the hash for cache busting:
from djcrud.static import vite_asset
...
Script(vite_asset("spa_example/js/app.js"), type="module")
vite_asset falls back to the original path when no manifest is present.
Frontend build configuration¶
Configure Vite to emit content-hashed filenames (for cache busting) and the
manifest consumed by vite_asset():
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: '../static/spa_example/js',
emptyOutDir: true,
manifest: true,
rollupOptions: {
input: 'src/main.js',
output: {
entryFileNames: 'app.[hash].js',
assetFileNames: 'app.[hash].[ext]',
},
},
},
})
Key settings:
manifest: trueEmits.vite/manifest.json(next to the built files) that maps your original entry point to the final hashed filename(s).entryFileNames/assetFileNamescontaining[hash]Ensures the output files include a content hash (e.g.app.BDdKb6Vl.js). This is the main mechanism that prevents stale browser caches after redeploys.outDirPoints the build output directly into a Django static directory (relative to the frontend package). The example uses../static/spa_example/jsso the files are served automatically bydjango.contrib.staticfiles.emptyOutDir: trueCleans the output directory on each build so old hashed files do not accumulate.
The built files (including the manifest) are committed in the tutorial
repository so /spa/ works without running the frontend build. Re-run
npm run build only when you change the client-side code.
Authentication¶
The HTML shell uses the same rules as every djcrud view:
Anonymous users are redirected to login before the SPA page renders (
dispatch()).Logged-in users must pass
has_permission()(superuser by default, or a grant fromadd_perm()).CSRF meta tags are in
base_spa.html; Unpoly picks them up viaunpoly-config.js.
The Svelte demo calls /api/product/ with credentials: 'same-origin', so
the browser sends the Django session cookie and DRF accepts the request when the
user is already logged in (requires DRF API). For token-based clients, use
djcrud_api (POST /api/login/ and Bearer auth) —
see the codegen section below.
SPA shell template¶
djcrud/base_spa.html renders a collapsible sidebar (is-hidden by
default), introspected navigation, CSRF meta tags, the mount node (see
Mounting your app), and {{ view.media.css }} / {{ view.media.js }}
from the view’s Media — no inline JavaScript.
Svelte app¶
Import hamburger.js and render the burger inside a Bulma .navbar so span
colors match the standard shell. The component toggles the server-rendered
#sidebar:
<script>
import { onMount } from 'svelte'
let products = $state([])
let apiError = $state('')
let loading = $state(true)
onMount(async () => {
try {
const response = await fetch('/api/product/', {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
products = await response.json()
} catch (err) {
apiError = err.message
} finally {
loading = false
}
})
</script>
<div class="djcrud-spa-panel">
<nav class="navbar djcrud-spa-toolbar" role="navigation" aria-label="Toggle navigation">
<div class="navbar-brand">
<div class="navbar-item">
<hamburger-menu target="#sidebar"></hamburger-menu>
</div>
</div>
</nav>
<section class="p-5">
<h1 class="title">SPA demo</h1>
<p class="subtitle">
The burger lives in your SPA; djcrud renders the collapsible sidebar in
<code>base_spa.html</code>.
</p>
<p>
Menu links use <code>up-follow="false"</code> so leaving the SPA reloads the
standard shell with navbar and server sidebar.
</p>
<h2 class="title is-5 mt-5">API products</h2>
{#if loading}
<p>Loading <code>/api/product/</code>…</p>
{:else if apiError}
<p class="has-text-danger">
Could not load products ({apiError}). Enable <code>djcrud[drf]</code> per
<code>docs/tutorial/drf.rst</code>, or run <code>npm run api</code> to
generate a client from <code>/api/schema/</code>.
</p>
{:else if products.length === 0}
<p>No products yet. Create one via the DRF API or HTML CRUD UI.</p>
{:else}
<ul>
{#each products as product (product.id)}
<li>{product.name}</li>
{/each}
</ul>
{/if}
</section>
</div>
Rebuild the committed bundle after editing Svelte sources:
cd src/djcrud_example/spa_example/frontend
npm ci
npm run build
Try it¶
Log in and visit
http://localhost:8000/spa/.
Open the sidebar with the burger, then jump to /item/ or other tutorial apps;
each hop reloads the matching shell.
The SPA demo is tagged with navigation so it appears in the standard sidebar
too — useful for entering the SPA from a normal page.
Client codegen¶
Call your DRF endpoints from the SPA without hand-writing fetch wrappers.
GET /api/schema/ describes login and every registered ViewSet — use it as
the single contract for Swagger, Postman, and JavaScript clients.
Export the schema¶
Offline — no running server:
python manage.py spectacular --file openapi.json --format openapi-json
From the SPA frontend package:
cd src/djcrud_example/spa_example/frontend
npm run api:schema
Generate the JavaScript client¶
The example uses OpenAPI Generator with
the javascript target (fetch + promises):
cd src/djcrud_example/spa_example/frontend
npm run api:generate
Both steps: npm run api. The generated src/api/ tree is gitignored;
regenerate after model or ViewSet changes.
Example usage¶
import { AuthApi, ProductApi } from "./api/src/index.js";
import { ApiClient } from "./api/src/ApiClient.js";
const client = new ApiClient("/api");
const auth = new AuthApi(client);
const { token } = await auth.apiLogin({ username: "su", password: "su" });
client.authentications.BearerAuth.accessToken = token;
const products = new ProductApi(client);
const list = await products.productList();
When the user is already logged in via the HTML session (as on /spa/), you
can also call /api/product/ with credentials: 'same-origin' — see
spa_example/frontend/src/App.svelte.
Alternative generators¶
Any OpenAPI 3 toolchain works against /api/schema/. Popular choices:
@hey-api/openapi-ts — fetch-native, pairs well with Vite
orval — hooks and functions (often TypeScript)
Pick one generator; djcrud maintains the schema, not the client output.
Tests¶
Browser tests in
tests/test_spa_browser.pyverify full-page transitions between the standard shell and the SPA.