Build A Modern Web App Using Django And Javascript


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.

Overview of frontend architecture for Django Applications

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.

Server-side rendered templates

HTML templates are rendered on the server, this is the traditional approach.

  • Django templates with javascript added throughout your templates.
  • Leverages all of Django's built in features.
  • Great for small teams, allows for quick prototyping.
  • The potential downside is frontend code can be large and disorganized (but does not have to be).

SPA (single page app) frontend with Django as a Rest API

Django is used purely as a backend with communication via an API.

  • Great for larger teams with specialized frontend and backend developers as different teams can work simultaneously without conflicting with each other's work.
  • Using a frontend framework simplifies the building of complex frontend applications.
  • One downside is that a lot of Django's built-in features cannot be used e.g. views, authentication, templates, and forms.

Hybrid of the first two approaches

Javascript is used when needed, otherwise traditional django templates are used.

  • This approach aims to be the best of both worlds,
  • use server side Django pages when little javascript is required, for example landing pages
  • use client first javascript when more complex UI elements are required

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.

Learn by doing - let's build an application

In this next section, we look at the important aspects of building an application using Django and modern javascript.

Project Requirements

  • A minimum viable product (MVP) web application with a landing page and a dashboard. The dashboard will have a Todos app and Chart App
  • Use javascript when required, for example to make the dashboard interactive. This means taking the hybrid approach,
    • use traditional Django templates for the landing page
    • use javascript to add interactivity to the dashboard
  • We will not use any build tools. Instead, we will use Alpine. An alternative would be to use Vue.
  • Our MVP needs to be beautiful, we will use TailwindCSS and DaisyUI.


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.

The end product

  • Landing page - server rendered page with no javascript, uses simple Django templates.
Landing Page - Django & Tailwind
  • Dashboard using both Django templates to initially render the page and JS to add interactions
    • Main dashboard page is a Django template view
    • Routing for the todo and chart views frontend to be handled by Alpine
    • Data fetching for the Todo app will be also handled by Alpine
Dashboard landing page

Structuring your project

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
│       ├──
│       ├──
│       ├──
│       ├──
│       ├── # not standard, will use for the api later
│       └──
|   └── apps # All apps in this folder
|      ├── dashboard
│        ├──
│        ├──
│        ├──
│        ├── # not standard, will use for the api later
│        ├── # not standard, will use for the api later
│        └──
├─── templates
|   ├── base.html
|   ├── home.html
|   └── dashboard
|      ├── components
|      ├── layout.html
|      ├── home.html
|   └── components
|      ├── header.html
|      ├── logo.html
├──── static # static files
|   ├── js
|   ├── css
|   └── images


  • 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 and 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 details
    • components 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.

Setup the base and landing page templates

Creating the base template

The base.html template includes all the required global scripts that will be used across the app.


{% load static i18n %}
<!DOCTYPE html>
{% get_current_language as language_code %}

<html lang="{{ language_code }}">

  <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="" rel="stylesheet" type="text/css" />
  <link href="" rel="stylesheet" type="text/css" />

  {# Alpine and app scripts #}
  <script defer src=""></script>  


<body id="id-body" class="{{theme}}">
  <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 %}

{% block footer_js %}

  <script type="text/javascript" src="{% static 'js/cookie.js' %}"></script>
  <script src=""></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))


{% endblock %}



  • <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 backend
  • const 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.

home page template

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.


{% extends 'base.html' %}

{% block content %}
    {% include "components/header.html" %}
{% endblock %}


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("", cache_page(60 * 15)(TemplateView.as_view(template_name="home.html")), name="home"),


  • 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,, devdojo, and many more.
  • config/ - uses a simple Django TemplateView defined directly in config/ You will note we have added cache_page to avoid dynamically generating the page every time a request is made.

Views , Models and API

In this next step we will setup a dashboard view and required api endpoints.

What we want to achieve:

  • use traditional Django templates to initially render the main dashboard landing.html page
  • include any additional data in the page context to avoid having to make extra API calls from the frontend
  • setup api endpoints for the todo's app

Create the model


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):
        ("done", "done"),
        ("outstanding", "outstanding"),
    detail = models.CharField(max_length=500)
    created = models.DateField(, null=False)
    status = models.CharField(
        max_length=100, null=True, blank=True, choices=STATUS_CHOICES
    updated = models.DateField(, null=False)

    objects = TodoManager()

    def to_json(self):

        return {
            "created": self.created.strftime("%Y-%m-%d"),
            "detail": self.detail,
            "status": self.status,


  • 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.

Create the views and API

Next, we will create our template view for the dashboard landing page.


from django.utils import timezone
from django.views.generic import TemplateView

class DashboardView(TemplateView):

    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"},


  • DashboardView - we modified the get_context_datamethod 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.


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] =
    status: Optional[str] = "outstanding"

    class Config:
        model = Todo
        model_fields = ["id", "detail", "created", "status"]


  • 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.


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:
        return {"message": f"deleted to with id {todo_id}"}

    return router.create_response(
        {"message": "Delete failed obj not found"},
    )"/", operation_id="addTodo")
def post_todo(request, payload: TodoSchema):
    """Add todo"""
    obj = Todo.objects.create(detail=payload.detail, status=payload.status)
    return obj.to_json()


  • 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.
  • added all the endpoints required to perform basic crud operations. You will notice that on the post_todo endpoint the payload should adhere TodoSchema data type. If you need more guidance on this, check out the Django ninja docs.

Wire up the routers and URLs

In this section, we will wire up the DashboardView and api endpoints to our URL configuration.


from django.urls import path

from . import views

app_name = "dashboard"
urlpatterns = [


from ninja import NinjaAPI
from apps.dashboard.api import router as todos_router

api = NinjaAPI()

api.add_router("/todos/", todos_router)


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("", cache_page(60 * 15)(TemplateView.as_view(template_name="home.html")), name="home"),
    path("", include("apps.dashboard.urls")),
    path("api/", api.urls),


apps/dashboard/ - we added the DashboardView to the dashboard URL configuration file.

apps/config/ - imported the todos router we created earlier and added it to our API.

apps/config/ - updated the main to include the dashboard app URL config and API endpoints.

The landing page and API docs should now be live 👌🏼.

visit http:localhost:8000/api/docs to see the docs.

Create the dashboard templates and alpine component

Next, we will create the templates for the dashboard landing page, as well as the dashboard javascript component.

Summary of the workflow

  • create the dashboard layout template
  • add the landing page, include AlpineJS attributes directly in the markup but do not inline javascript directly on the page.
  • create the templates for the Chart app and Todo app

">Creating the dashboard layout and landing page templates


{% extends 'base.html' %} 
{% block content %}

<div class="flex flex-col">
    <nav class="relative">...</nav>
 {% block dashboard_content %}{% endblock %}

{% endblock content %}


  • this is a normal Django template that extends base.htmlthe template includes a dashboard navbar which should appear on every dashboard page.


{% 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">
          <a @click="changeView('index')">Dashboard</a>
        <li x-show="activeView.key !== 'index' ">
          <a x-text=""></a>

    <a x-show="activeView.key !== 'index'" x-cloak @click="changeView('index')" class="btn btn-primary btn-sm mt-2">
      <svg xmlns="" 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" />

  <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="">
        <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=""></h2>

        <div x-show="activeView.key == 'chart'" x-cloak>
        {% include "dashboard/components/charts.html" %}

        <div x-show="activeView.key == 'todo'" x-cloak>
        {% include "dashboard/components/todo.html" %}


{% endblock %}

{% block footer_js %}
  {{ block.super }}

  <script type="text/javascript" src=""></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#}
    const listIconUrl = "{% static "icons/list.svg" %}"
    const chartIconUrl = "{% static "icons/list.svg" %}"

  <script type="text/javascript" src="{% static 'js/charts.js' %}"></script>
  <script type="text/javascript" src="{% static 'js/dashboard.js' %}"></script>

{% endblock %}


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=""> - 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/

Other required templates

You can refer to the template/dashboard/components in the codebase for the todo.html, chart.html and todo_form.html template files.


 <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 - 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.

Creating the dashboard Alpine Component

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.


// 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: [],
            todo: {
                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("view")
            if (this.appList.includes(key)) {
            // update 'view' url search params to show correct view
            if(key===null || key !== null && !this.appList.includes(key)){
                this.updateHistoryState('index', true)
        /** Handle window.popstate event
         *  When a user navigates to the previous page we should update the view
            window.onpopstate = () => setTimeout(()=> {
                    let url= new URL(window.location)
                    let key = url.searchParams.get('view')
            }, 0);
        /** check navigation updated */
            let url= new URL(window.location)
            return this.activeView.key === url.searchParams.get('view')

        /**  Dashboard routing */
        async changeView(key) {
            this.loading = true
            // fetch relevant data for view and navigate to view
            try {
                this.activeView = this.views[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);
                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) {
       = true,
                // TODO add -> this.fetchTodos()
       = false

            // update chart view
            if (key === this.apps.chart.key) {
                // instead of an api call we use the data already rendered by django
       = JSON.parse(document.getElementById('chartData').textContent)
                chartComponent = echarts.init(document.getElementById('chartArea'));
                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.


  • 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.
    • = JSON.parse(document.getElementById('chartData').textContent) - consumes data directly from the page context instead of using the API.
    • if (key === this.apps.chart.key) ... - logic for when a user navigates to the Chart App view.

Updating the dashboard Alpine component - CRUD methods for the Todo app

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.


function dashboard() {
    return {
       /** the following can be refactored to a separate todos app */
        /** fetch todos */
            client.then(client => client.apis.todos.getTodos())
            .then(result => {
       = result.obj
            .catch((error) => alert(error));
        /** show todos */
   = !
            if(clear){ = {detail: ''}}
        /** save todo form */
        /** add todos */
            client.then(client => client.apis.todos.addTodo({}, {requestBody: data}))
            .then(result =>{
            .catch((error) => alert('error adding todo'));
        /** add todos */
        deleteTodo(id, index){
            client.then(client => client.apis.todos.deleteTodo({todo_id: id}))
            .then(result =>{
      , 1)
            .catch((error) => console.error(error));


  • fetchTodos(), addTodo(data), deleteTodo(id, index) - CRUD methods for the Todos app
    • we are using the swagger-js client 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/ 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.

Todo's app with modal open.

Adding a function to build chart options for the Chart App

The app uses the echarts library to render a traffic stats in an app.


function buildChartOptions (chartData){


  •  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.
Chart App View

All done 🥳 🥳 !!

Are you building a SaaS product and want to launch like ASAP? Check out the Vanty Starter Kit. Our Django boilerplate has everything you need to launch your app today!

Learn more →


  • Choosing the architecture - Django gives you the flexibility to use any approach for your frontend architecture. Make the decision based on the requirements of the project and take advantage of what Django has to offer.
  • Structuring your project - a well-structured project is easy to maintain and refactor. Take the time to structure your project before starting out. Whenever possible try to keep your JS and HTML separate.
  • Client-side javascript frameworks - you will most likely need to use javascript in your Django application. Think twice before reaching for the nearest big client-side javascript framework. In some cases, a smaller framework like Alpine or even vanilla javascript will suffice.

Further reading

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.

Last Updated 25 Sep 2022