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: true Emits .vite/manifest.json (next to the built files) that maps your original entry point to the final hashed filename(s).

  • entryFileNames / assetFileNames containing [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.

  • outDir Points the build output directly into a Django static directory (relative to the frontend package). The example uses ../static/spa_example/js so the files are served automatically by django.contrib.staticfiles.

  • emptyOutDir: true Cleans 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 from add_perm()).

  • CSRF meta tags are in base_spa.html; Unpoly picks them up via unpoly-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

Cross-shell navigation

Standard djcrud pages swap content inside [up-main]. SPA pages use a different document shell (no top navbar). When the active view uses a base_spa.html template, unpoly_link_attributes() returns plain links for every menu item so the browser always full-reloads.

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