In this article, you will learn how to use modern javascript in your Django application without giving up the advantages of Django.
Client-side javascript is a core part of modern interactive web applications. Popular modern javascript frameworks you could use for your Django application include React, Angular, and VueJS. There are also lighter javascript frameworks like Alpine.js (Alpine) that provide powerful tools for composing behavior directly in HTML without the need for larger frontend frameworks that may require build tools or resorting to jQuery.
Frontend architecture refers to how the frontend code is organized. There are three common approaches for organizing your Django frontend code. Each has its merits and disadvantages.
HTML templates are rendered on the server, this is the traditional approach.
Django is used purely as a backend with communication via an API.
Javascript is used when needed, otherwise traditional django templates are used.
The choice of approach depends on your project requirements, for example, team size or the level of complexity of the front end. The hybrid approach has worked best for us as our projects tend to range from small to medium size and are built by small highly productive teams.
In this next section, we look at the important aspects of building an application using Django and modern javascript.
LEVEL - 💻 💻 Intermediate-Advanced. This will not be a step-by-step tutorial but we will cover the main concepts. We advise that you download the code if you want to follow along. The tutorial is fast-paced and assumes knowledge of Django and javascript.
Clone the repo from here. In this first step, we will structure our project for maintainability and developer happiness. Place everything where it should be, with a clear separation of concerns. Let's take a brief look at the project structure.
folder structure
├── project
| └── config # Optional keeps config separate from apps
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ ├── asgi.py
│ ├── api.py # not standard, will use for the api later
│ └── wsgi.py
| └── apps # All apps in this folder
| ├── dashboard
│ ├── apps.py
│ ├── urls.py
│ ├── views.py
│ ├── api.py # not standard, will use for the api later
│ ├── schema.py # not standard, will use for the api later
│ └── models.py
├─── templates
| ├── base.html
| ├── home.html
| └── dashboard
| ├── components
| ├── layout.html
| ├── home.html
| └── components
| ├── header.html
| ├── logo.html
├──── static # static files
| ├── js
| ├── css
| └── images
└─── manage.py
Notes
apps folder
- the project will only have one app called 'dashboard', we can already generate this app and place it in our apps folder.apps/dashboard
& config
folders - you will notice additional files api.py and schema.py. These are not generated by Django. We have added them for completeness since we already know our project will need an API.templates
base.html
- all the main pages will extend this template see below for more detailscomponents folder
static
- static file including js, images and styles.js
- In this project, we will be creating javascript components. Separating out javascript from our markup (HTML) will ensure that our codebase is maintainable. It will also make it easier to switch to a frontend framework if necessary.The base.html template includes all the required global scripts that will be used across the app.
templates/base.html
{% load static i18n %}
<!DOCTYPE html>
{% get_current_language as language_code %}
<html lang="{{ language_code }}">
<head>
<title>{% spaceless %}{% block title %}Home{% endblock %}{% endspaceless %} </title>
<meta charset="UTF-8">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{# site meta and seo meta tags, override on specific pages as necessary. #}
{% block site_meta %}{% endblock %}
{% block seo-meta %}
<meta name="robots" content="index,follow">
{% endblock %}
{% block extra_css %}{% endblock %}
{% block head_js %}{% endblock %}
<link href="{% static 'css/site.css' %}" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet" type="text/css" />
{# Alpine and app scripts #}
<script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
</head>
<body id="id-body" class="{{theme}}">
<div>
<div class="flex h-screen antialiased overflow-x-hidden bg-base-200 text-base-content">
<div class="{% block page_wrapper_class %}h-full w-full overflow-hidden fixed{% endblock %}">
{# content goes here #}
{% block content %}{% endblock %}
</div>
</div>
</div>
{% block footer_js %}
<script type="text/javascript" src="{% static 'js/cookie.js' %}"></script>
<script src="https://unpkg.com/swagger-client"></script>
<script>
// Cookie func
const csrftoken = getCookie('csrftoken');
const authHeaders = {'X-CSRFToken': csrftoken}
// the application uses the swagger js client to interact with the api
// if you are building a separate frontend, consult the docs on how to generate a client in typescript
const client = SwaggerClient('/api/openapi.json', {
requestInterceptor: (req) => {
req.headers['X-CSRFToken'] = csrftoken;
req.headers['Content-Type'] = 'application/json';
return req;
},
})
//uncomment to log api endpoints to console for debugging purposes
client.then(client => console.log(client.apis))
</script>
{% endblock %}
</body>
</html>
Notes
<head>...</head>
- this section includes TailwindCSS,(styling) DaisyUI (pre-made UI components) AlpineJS required. In production, these files should be minified, but this is outside the scope of this article. You can visit the Tailwindcss docs for further instructions.const csrftoken
- add the csrftoken to the page, this will be required for Ajax requests to the backendconst client = SwaggerClient('/api/schema/',..
- set up a swagger-js client. As we will demonstrate later, this is a nice addition to which will make interacting with the backend API more pleasant.The landing page extends the base.html template and only includes one, plain html header component. We expect this page to be static over time.
templates/home.html
{% extends 'base.html' %}
{% block content %}
{% include "components/header.html" %}
{% endblock %}
config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.views.generic import TemplateView
from django.views.decorators.cache import cache_page
urlpatterns = [
path("admin/", admin.site.urls),
path("", cache_page(60 * 15)(TemplateView.as_view(template_name="home.html")), name="home"),
]
Notes
templates/home.html
- our landing page only includes a header components/header.html section, for now, you can easily extend this to add a footer. For this article, we used an online tailwind page builder. You can use a ton of resources to build high-quality landing pages fast, for example, shuffle.dev, devdojo, and many more.config/urls.py
- uses a simple Django TemplateView
defined directly in config/urls.py
. You will note we have added cache_page
to avoid dynamically generating the page every time a request is made. In this next step we will setup a dashboard view and required api endpoints.
What we want to achieve:
apps/dashboard/models.py
from django.db import models
from django.utils import timezone
class TodoManager(models.Manager):
def to_list(self):
return [todo.to_json() for todo in self.get_queryset()]
class Todo(models.Model):
STATUS_CHOICES = [
("done", "done"),
("outstanding", "outstanding"),
]
detail = models.CharField(max_length=500)
created = models.DateField(default=timezone.now, null=False)
status = models.CharField(
max_length=100, null=True, blank=True, choices=STATUS_CHOICES
)
updated = models.DateField(default=timezone.now, null=False)
objects = TodoManager()
def to_json(self):
return {
"id": self.id,
"created": self.created.strftime("%Y-%m-%d"),
"detail": self.detail,
"status": self.status,
}
Notes:
TodoManager
- extend manager to include business logic for our todos app in this case returns a list of formatted todos.def to_json(self)
: - added a convenience method to format our todos for the frontend.Next, we will create our template view for the dashboard landing page.
apps/dashboard/views.py
from django.utils import timezone
from django.views.generic import TemplateView
class DashboardView(TemplateView):
template_name="dashboard/landing.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({"chart_data": self.get_chart_data()})
return context
def get_chart_data(self):
"""return chart data"""
return [
{"value": 335, "name": "Direct"},
{"value": 310, "name": "Email"},
{"value": 274, "name": "Union Ads"},
{"value": 235, "name": "Video Ads"},
{"value": 400, "name": "Search Engine"},
]
Notes
DashboardView
- we modified the get_context_data
method to add additional data for the Chart. This is embedded in the page context and saves us from having to make an extra API call on the frontend Chart App to fetch this data. Let's add our backend API. We are using the Django Ninja library as a replacement for the excellent Django rest framework library. The library uses Pydantic for data validation, has async and type hints support.
apps/dashboard/schema.py
from typing import Optional
from datetime import datetime
from django.utils import timezone
from ninja import ModelSchema
from .models import Todo
class TodoSchema(ModelSchema):
created: Optional[datetime] = timezone.now()
status: Optional[str] = "outstanding"
class Config:
model = Todo
model_fields = ["id", "detail", "created", "status"]
Notes:
TodoSchema
- the schema serializes (convert data to python types) and deserializes data (convert data to content types such as JSON or XML). This is the equivalent of a serializer class in the Django rest framework.apps/dashboard/api.py
from ninja import Router
from .models import Todo
from .schema import TodoSchema
router = Router(tags=["todos"])
@router.get("/", operation_id="getTodos")
def list_todos(request):
"""List todos"""
todos = Todo.objects.to_list()
return todos
@router.get("/{todo_id}", operation_id="getTodo")
def todo_details(request, todo_id: int):
"""Retrive todo"""
todo = Todo.objects.get(id=todo_id)
return todo.to_json()
@router.delete("/{todo_id}", operation_id="deleteTodo")
def delete_todo(request, todo_id: int):
"""Delete todo"""
obj = Todo.objects.filter(id=todo_id).first()
if obj is not None:
obj.delete()
return {"message": f"deleted to with id {todo_id}"}
return router.create_response(
request,
{"message": "Delete failed obj not found"},
status=404,
)
@router.post("/", operation_id="addTodo")
def post_todo(request, payload: TodoSchema):
"""Add todo"""
obj = Todo.objects.create(detail=payload.detail, status=payload.status)
obj.save()
return obj.to_json()
Notes:
router
- we created the todos router and added the tag 'todos'. This will ensure the correct tag is included in our api docs view. We will refer back to this file when we wire up the routers and urls below.post_todo
endpoint the payload should adhere TodoSchema data type. If you need more guidance on this, check out the Django ninja docs.In this section, we will wire up the DashboardView
and api
endpoints to our URL configuration.
apps/dashboard/urls.py
from django.urls import path
from . import views
app_name = "dashboard"
urlpatterns = [
path(
"dashboard/",
views.DashboardView.as_view(),
name="landing",
),
]
apps/config/api.py
from ninja import NinjaAPI
from apps.dashboard.api import router as todos_router
api = NinjaAPI()
api.add_router("/todos/", todos_router)
apps/config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.views.generic import TemplateView
from django.views.decorators.cache import cache_page
from .api import api
urlpatterns = [
path("admin/", admin.site.urls),
path("", cache_page(60 * 15)(TemplateView.as_view(template_name="home.html")), name="home"),
path("", include("apps.dashboard.urls")),
path("api/", api.urls),
]
Notes:
apps/dashboard/urls.py
- we added the DashboardView
to the dashboard URL configuration file.
apps/config/api.py
- imported the todos router we created earlier and added it to our API.
apps/config/urls.py
- updated the main urls.py to include the dashboard app URL config and API endpoints.
The landing page and API docs should now be live 👌🏼.
Next, we will create the templates for the dashboard landing page, as well as the dashboard javascript component.
Summary of the workflow
templates/layout.html
{% extends 'base.html' %}
{% block content %}
<div class="flex flex-col">
<section>
<nav class="relative">...</nav>
</section>
{% block dashboard_content %}{% endblock %}
</div>
{% endblock content %}
Notes:
base.html
the template includes a dashboard navbar which should appear on every dashboard page.templates/landing.html
{% extends 'dashboard/layout.html' %}{% load static %} {% block dashboard_content %}
<div x-data='dashboard()' class="flex flex-col">
<div class="w-full lg:max-w-7xl flex justify-between align-items-center mt-10 p-2 lg:px-6">
<div class="text-lg md:text-3xl font-bold breadcrumbs">
<ul>
<li>
<a @click="changeView('index')">Dashboard</a>
</li>
<li x-show="activeView.key !== 'index' ">
<a x-text="activeView.name"></a>
</li>
</ul>
</div>
<a x-show="activeView.key !== 'index'" x-cloak @click="changeView('index')" class="btn btn-primary btn-sm mt-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L7.414 9H15a1 1 0 110 2H7.414l2.293 2.293a1 1 0 010 1.414z"
clip-rule="evenodd" />
</svg>
<span>Back</span>
</a>
</div>
<div class="p-6 lg:max-w-7xl mt-6 mb-20">
<div x-show="activeView.key === 'index'" class="grid grid-cols-2 gap-10 xl:grid-cols-5">
<template x-for="(app, index) in apps" :key="app.name">
<div @click="changeView(app.key)" class="card side bg-base-100 compact shadow dark:shadow-lg hover:shadow-xl bordered pointer">
<div class="flex flex-col justify-start p-4 text-primary ">
<div class="flex flex-row mx-auto">
<img :src="app.icon" height="24" width="24"/>
<h2 class="text-lg text-base-content ml-3 font-bold" x-text="app.name"></h2>
</div>
</div>
</div>
</template>
</div>
<div x-show="activeView.key == 'chart'" x-cloak>
{% include "dashboard/components/charts.html" %}
</div>
<div x-show="activeView.key == 'todo'" x-cloak>
{% include "dashboard/components/todo.html" %}
</div>
</div>
</div>
{% endblock %}
{% block footer_js %}
{{ block.super }}
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js"></script>
{# load any variables required by our scripts that have to be rendered using django templates #}
{{chart_data|json_script:"chartData"}} {# backend provided json data#}
<script>
const listIconUrl = "{% static "icons/list.svg" %}"
const chartIconUrl = "{% static "icons/list.svg" %}"
</script>
<script type="text/javascript" src="{% static 'js/charts.js' %}"></script>
<script type="text/javascript" src="{% static 'js/dashboard.js' %}"></script>
{% endblock %}
Notes:
There is a lot going on here, let's breakdown the important bits
x-data='dashboard()'
- Alpine directive that defines a chunk of HTML as an Alpine component. Properties defined in the dashboard Alpine component will be available to all the element children. The alpine component itself is included on the page using this line <script type="text/javascript" src="{% static 'js/dashboard.js' %}"></script>
, we will compose it later. @click="changeView('index')"
- @ is a shortcut for the alpine x-bind: directive. In this case, it binds the click event to the changeView
function defined in the dashboard component.x-show="activeView.key !== 'index'"
- x-show provides an expressive way to hide or show DOM elements. On this page, it will hide or show the index view, chart app view, or todo app view depending on what the activeView variable is set to.x-cloak
- to avoid the incorrect view showing before alpine loads, we add the x-cloak direct to each of the views. In our static/styles/site.css file, we also add the required CSS for this to work [x-cloak] { display: none !important; }
<template x-for="(app, index) in apps" :key="app.name">
- this section is to show the correct app icon. We use the x-for directive to create DOM elements by iterating through a list. This is an example of client-side javascript rendering HTML.{{chart_data|json_script:"chartData"}}
- in the DashboardView we included chart_data in the page context. This json_script template tag will transform the Python object as JSON wrapped in a script tag which can be consumed by javascript.const listIconUrl = "{% static "icons/list.svg" %}"
- we also include URLs for our app icons. These will be used in the alpine dashboard component. We are defining them before we import our page js files so that they are available when Alpine loads the component.At this stage, we have defined the templates but not the Alpine component. Therefore, only the header will be rendered if we visit http://localhost:8000/dashboard/
You can refer to the template/dashboard/components in the codebase for the todo.html
, chart.html
and todo_form.html
template files.
todo_form.html
...
<div class="form-control flex w-full mt-4 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<textarea type="text" placeholder="add todo detail" class="textarea textarea-primary textarea-bordered"
x-model='data.todo.form.detail'>
</textarea>
</div>...
Notes:
x-model
- one thing to note here is the use of the x-model
directive, which binds the value of the textarea to Alpine data. There are no other new concepts in these templates that we have not covered above.
This is our client side javascript application. It will handle the routing for the Todo, Chart and dashboard landing views as well as logic required for data fetching etc.
static/js/dashboard.js
// constant for the chart component
let chartComponent;
const index = {
name: "home", id: 1, key: "index",
}
// Define a constant for the dashboard views
const dashboardApps = {
todo: {
name: "Todo App", id: 2, key: "todo",
icon: listIconUrl,
description: "todo app"
},
chart: {
name: "Chart App", id: 3, key: "chart",
icon: chartIconUrl,
description: "chart app"
},
}
/**
* Alpine Dashboard Component
* init - entry point for the alpine component
*
* */
function dashboard() {
return {
apps: dashboardApps,
views: {index, ...dashboardApps},
activeView: index,
data: {
chart: {
chartData: [],
isLoading:false
},
todo: {
list:[],
isLoading:false,
form: {detail: ''},
showForm: false
}
},
hasNavigated: true,
loading: false,
appList: ["todo", "chart"],
/**
* Initialises dashboard view
* Use the url to render the correct view
*
*/
init() {
// check for key from url
let key = new URLSearchParams(location.search).get("view")
console.log(key)
if (this.appList.includes(key)) {
this.changeView(key)
}
// update 'view' url search params to show correct view
if(key===null || key !== null && !this.appList.includes(key)){
this.updateHistoryState('index', true)
}
this.setPopStateEventListener()
},
/** Handle window.popstate event
* When a user navigates to the previous page we should update the view
*/
setPopStateEventListener(){
//https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
window.onpopstate = () => setTimeout(()=> {
if(!this.checkNavigationUpdated()){
let url= new URL(window.location)
let key = url.searchParams.get('view')
this.changeView(key)
}
}, 0);
this.checkNavigationUpdated()
},
/** check navigation updated */
checkNavigationUpdated(){
let url= new URL(window.location)
return this.activeView.key === url.searchParams.get('view')
},
/** Dashboard routing */
async changeView(key) {
console.log(key)
this.loading = true
// fetch relevant data for view and navigate to view
try {
this.activeView = this.views[key]
this.updateHistoryState(key)
await this.setupView(key)
} catch (e) {
console.log(`${e} Error loading view`)
}
this.loading = false
},
/** Update browser's session history */
updateHistoryState(view, replace=false, key='view'){
try {
const url = new URL(window.location);
url.searchParams.set(key, view);
replace ? window.history.replaceState({}, '', url) : window.history.pushState({}, '', url);
}catch(e){
console.log(`${e} Error updating history state`)
}
},
/** Handle additional logic required before rendering a view, e.g. fetching data */
async setupView(key) {
// update todos
if (key === this.apps.todo.key) {
this.data.todo.isLoading = true,
// TODO add -> this.fetchTodos()
this.data.todo.isLoading = false
}
// update chart view
if (key === this.apps.chart.key) {
// instead of an api call we use the data already rendered by django
this.data.chart.chartData = JSON.parse(document.getElementById('chartData').textContent)
chartComponent = echarts.init(document.getElementById('chartArea'));
chartComponent.setOption(buildChartOptions(this.data.chart.chartData))
setTimeout(chartComponent.resize(), 100)
}
},
/** TODO */
/** fetch todos */
/** add todos */
/** delete todos etc */
}
}
This is our basic alpine component, there are some bit and pieces missing, like logic for the todo app for example which we will extend later.
Notes:
const dashboardApps
- javascript object representing the dashboard views, includes index(landing page) and chart and todo apps. This is mapped to the apps variable in the alpine dashboard component.init()
- this is the entry point for our alpine component called when the component is finalized. Updates the URL to show the correct view. In our case, it will replace the current URL search params to show ?view=index if the view search param is not defined.changeView
, updateHistoryState
- contains logic for routing and updating the browser history session.setPopStateEventListener
- listens to popstate events, i.e. when a user clicks the back button and call changeView
to update the UI.async setupView(key)
.. - handles any additional logic for fetching data from the backend and setting up components.this.data.chart.chartData = JSON.parse(document.getElementById('chartData').textContent)
- consumes data directly from the page context instead of using the API.
We will update our alpine dashboard component (same file as above) to include CRUD methods for the Todo's app. In practice, this can be split out into another Alpine component. We have included it in the same component to keep things simple.
static/js/dashboard.js
function dashboard() {
return {
.......
/** the following can be refactored to a separate todos app */
/** fetch todos */
fetchTodos(){
client.then(client => client.apis.todos.getTodos())
.then(result => {
console.log(result)
this.data.todo.list = result.obj
})
.catch((error) => alert(error));
},
/** show todos */
toggleForm(clear=false){
this.data.todo.showForm = !this.data.todo.showForm
if(clear){this.data.todo.form = {detail: ''}}
},
/** save todo form */
saveTodoForm(){
this.addTodo(this.data.todo.form)
this.toggleForm(clear=true)
},
/** add todos */
addTodo(data){
client.then(client => client.apis.todos.addTodo({}, {requestBody: data}))
.then(result =>{
console.log(result.obj)
this.data.todo.list.push(result.obj)
})
.catch((error) => alert('error adding todo'));
},
/** add todos */
deleteTodo(id, index){
client.then(client => client.apis.todos.deleteTodo({todo_id: id}))
.then(result =>{
console.log(result.obj)
this.data.todo.list.splice(index, 1)
})
.catch((error) => console.error(error));
}
}
}
Notes:
fetchTodos()
, addTodo(data)
, deleteTodo(id, index)
- CRUD methods for the Todos appclient
we declared in our base.html template. instead of typing out the urls we use the operationIds we defined in our api. addTodo
- includes an empty object for the url parameters {} and the payload i.e. {requestBody: data}.deleteTodo
- includes {todo_id: id} which represents the required todo_id url parameter. This represents the id of the Todo object we want to delete. If you refer back to dashboard/api.py you will note that we use this id to search for the todo on the database.That's it, the todos app should be done.
The app uses the echarts library to render a traffic stats in an app.
static/js/charts.js
function buildChartOptions (chartData){
....
}
Notes:
buildChartOptions (chartData)
- returns the chart options required to build the chart. This method is called by the setupView()
function in dashboard.js
whenever a user navigates to the Chart app view.All done 🥳 🥳 !!
Alpine.js docs - Excellent introduction to Alpine
TailwindCSS - No brainer if you want to rapidly compose beautiful interface.
Modern Javascript basics - One of the best guides on Django and Javascript, slower paced, good for beginners.
Related Articles
All ArticlesSuccess
Error
Warning
Info