Programming • Jan 06, 2026 • Cliff

Building A Django SaaS Application

Building a Django SaaS App

Or The Django SaaS Mega-Tutorial

From Scratch to Subscription-Ready (Part 1)


TL;DR

This tutorial walks through building a clean, production-minded Django SaaS starter from scratch. You’ll set up environment-based settings, implement an email-first custom user model, wire authentication flows, and build a responsive Bootstrap UI. By the end, you’ll have a runnable local application suitable for free-tier users and ready to evolve into a paid SaaS.

Production deployment and subscription billing are intentionally deferred to later tutorials.


Who This Is For

This guide is written for:

You don’t need deep Django expertise, but you should be comfortable reading Python and using the command line.


Prerequisites


Table of Contents


One-Page Quick Reference

Key Commands

# Create and activate a virtual environment (Windows)
python -m venv .venv
.venv\Scripts\activate

# macOS / Linux
source .venv/bin/activate

# Install dependencies
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

# Create project and apps
django-admin startproject config .
python manage.py startapp accounts

# Migrations and local run
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

Key Files


Why Django for SaaS?

Django’s strength is not that it’s “simple,” but that it’s complete.

Authentication, admin tooling, ORM, migrations, and security primitives are all first-class. For SaaS products, this matters more than novelty. You want predictable behavior, strong defaults, and a framework that scales with complexity rather than fighting it.

This tutorial reflects that philosophy. We avoid shortcuts that feel convenient early but cause friction later, especially around authentication, settings, and structure.


Step 1: Environment Setup and Project Initialization

Goals

In this step, you will:

Virtual Environment

Always start with isolation. It prevents dependency conflicts and makes builds reproducible.

python -m venv .venv

Activate it:

# Windows
.venv\Scripts\activate

# macOS / Linux
source .venv/bin/activate

Dependencies

Create a requirements.txt file:

django
django-environ

Install:

python -m pip install -r requirements.txt

Why these?

We keep dependencies minimal on purpose.


Create the Project

django-admin startproject config .

Using config as the project name keeps the structure clean and avoids semantic confusion later when adding domain-specific apps.


Environment-Based Settings

Edit config/settings.py:

from pathlib import Path
import environ

BASE_DIR = Path(__file__).resolve().parent.parent

env = environ.Env()
env.read_env(BASE_DIR / '.env')

Replace hardcoded values:

SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env.bool('DJANGO_DEBUG', default=False)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[])

Create .env:

DJANGO_SECRET_KEY=replace-me-with-a-real-secret
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1

Why this matters

Hardcoded secrets leak. Environment variables scale cleanly from local dev to CI to production. .env.example doubles as documentation for collaborators and automation.

Validate early:

python manage.py check

Step 2: Custom User Model (Email-First)

Email-based authentication is the norm for SaaS. Django allows this cleanly, but only if you do it early.

Create the Accounts App

python manage.py startapp accounts

Custom User Model

accounts/models.py:

from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager

class CustomUserManager(BaseUserManager):
    def create_user(self, username, email, password=None):
        if not email:
            raise ValueError("Email is required")
        email = self.normalize_email(email)
        user = self.model(username=username, email=email)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, username, email, password):
        user = self.create_user(username, email, password)
        user.is_staff = True
        user.is_superuser = True
        user.save(using=self._db)
        return user

class CustomUser(AbstractBaseUser):
    username = models.CharField(max_length=150, unique=True)
    email = models.EmailField(unique=True)
    date_joined = models.DateTimeField(auto_now_add=True)

    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    is_superuser = models.BooleanField(default=False)

    objects = CustomUserManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    def __str__(self):
        return self.email

Register it in config/settings.py:

INSTALLED_APPS += ['accounts']
AUTH_USER_MODEL = 'accounts.CustomUser'

⚠️ Important AUTH_USER_MODEL must be set before initial migrations. Changing it later is painful and error-prone.

Run migrations:

python manage.py makemigrations accounts
python manage.py migrate

Step 3: Authentication Views, Forms, and Templates

This step wires user registration, login, logout, and profile views.

Forms with Bootstrap Styling

accounts/forms.py:

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import get_user_model

User = get_user_model()


class CustomUserCreationForm(UserCreationForm):
    email = forms.EmailField(
        required=True,
        help_text="Required. Enter a valid email address.",
        widget=forms.EmailInput(attrs={'class': 'form-control'}),
    )

    class Meta:
        model = User
        fields = ('username', 'email', 'password1', 'password2')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['username'].widget.attrs.update(
            {'class': 'form-control', 'placeholder': 'Enter your username'}
        )
        self.fields['password1'].widget.attrs.update(
            {'class': 'form-control', 'placeholder': 'Enter your password'}
        )
        self.fields['password2'].widget.attrs.update(
            {'class': 'form-control', 'placeholder': 'Confirm your password'}
        )
        self.fields['password1'].help_text = (
            'Your password must contain at least 8 characters.'
        )
        self.fields['password2'].help_text = 'Enter the same password as above.'

    def save(self, commit=True):
        user = super().save(commit=False)
        user.email = self.cleaned_data['email']
        if commit:
            user.save()
        return user

Why use UserCreationForm?

Django's built-in UserCreationForm provides critical functionality that you shouldn't rebuild from scratch:

By inheriting from UserCreationForm, we get all this functionality while adding our custom email field and Bootstrap styling.

Guideline: Add layout classes only. Avoid hardcoded colors that will clash with theming later.


Views and URLs

Use Django’s built-in auth views where possible. They are secure, tested, and boring in the best way.

Templates live under accounts/templates/accounts/ and extend a shared base.


Step 4: Core App and Shared UI

Create a core app for non-auth pages:

python manage.py startapp core

This app owns:

Base Template

Use Bootstrap 5 and a single shared navbar. Authentication state controls visible links. Logout is a POST form to avoid side effects from browser caching and to follow HTTP conventions (state-changing operations should use POST, not GET).

Theme toggling is client-side and minimal:

<script>
    const toggle = document.getElementById('theme-toggle');
    toggle?.addEventListener('click', () => {
        document.documentElement.classList.toggle('dark');
        localStorage.theme =
            document.documentElement.classList.contains('dark') ? 'dark' : 'light';
    });
</script>

Deep Dive: Shared Base Template and Authentication Pages

At this point in the tutorial, we’ve referenced a shared base template and several authentication pages without looking at them in detail. This section fills that gap.

These templates are intentionally simple, explicit, and boring. That’s a feature, not a bug. SaaS applications benefit from predictability and clarity, especially around authentication flows.


The Shared Base Template (core/base.html)

This file defines the global layout, navigation, Bootstrap inclusion, and light/dark theme toggle. Every page in the app extends it.

Full Listing: core/templates/core/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}My SaaS App{% endblock %}</title>

    <link
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
        rel="stylesheet"
        integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
        crossorigin="anonymous"
    >

    {% block extra_head %}{% endblock %}
</head>
<body>
    <nav class="navbar navbar-expand-lg bg-light navbar-light">
        <div class="container-fluid">
            <a class="navbar-brand" href="{% url 'index' %}">My SaaS App</a>

            <button
                class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav"
                aria-controls="navbarNav"
                aria-expanded="false"
                aria-label="Toggle navigation"
            >
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{% url 'index' %}">Home</a>
                    </li>

                    {% if user.is_authenticated %}
                    <li class="nav-item">
                        <a class="nav-link" href="{% url 'profile' %}">Profile</a>
                    </li>
                    {% endif %}
                </ul>

                <ul class="navbar-nav">
                    {% if user.is_authenticated %}
                        <li class="nav-item">
                            <form method="post" action="{% url 'logout' %}" style="display: inline;">
                                {% csrf_token %}
                                <button type="submit" class="btn btn-link nav-link" style="border: none; padding: 0; background: none; cursor: pointer;">Logout</button>
                            </form>
                        </li>
                    {% else %}
                        <li class="nav-item">
                            <a class="nav-link" href="{% url 'login' %}">Login</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="{% url 'register' %}">Register</a>
                        </li>
                    {% endif %}

                    <li class="nav-item">
                        <button
                            id="theme-toggle"
                            class="btn btn-link nav-link"
                            type="button"
                        >
                            🌙
                        </button>
                    </li>
                </ul>
            </div>
        </div>
    </nav>

    <div class="container mt-4">
        {% block content %}{% endblock %}
    </div>

    <script
        src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
        crossorigin="anonymous"
    ></script>

    <script>
        document.addEventListener('DOMContentLoaded', function () {
            const themeToggle = document.getElementById('theme-toggle');
            const html = document.documentElement;
            const navbar = document.querySelector('.navbar');

            const currentTheme = localStorage.getItem('theme') || 'light';
            html.setAttribute('data-bs-theme', currentTheme);
            updateNavbar();
            updateIcon();

            themeToggle.addEventListener('click', () => {
                const newTheme =
                    html.getAttribute('data-bs-theme') === 'dark'
                        ? 'light'
                        : 'dark';

                html.setAttribute('data-bs-theme', newTheme);
                localStorage.setItem('theme', newTheme);
                updateNavbar();
                updateIcon();
            });

            function updateNavbar() {
                const isDark = html.getAttribute('data-bs-theme') === 'dark';
                navbar.classList.toggle('bg-light', !isDark);
                navbar.classList.toggle('bg-dark', isDark);
                navbar.classList.toggle('navbar-light', !isDark);
                navbar.classList.toggle('navbar-dark', isDark);
            }

            function updateIcon() {
                const isDark = html.getAttribute('data-bs-theme') === 'dark';
                themeToggle.innerHTML = isDark ? '☀️' : '🌙';
            }
        });
    </script>

    {% block extra_js %}{% endblock %}
</body>
</html>

Why This Template Works Well

A few intentional choices worth calling out:

This template is stable enough to survive early SaaS growth without becoming a dumping ground for unrelated concerns.


Authentication Template Example: Login Page

The login template demonstrates a reusable pattern you’ll see across all auth-related pages: centered card layout, form iteration, and consistent error handling.

Full Listing: accounts/login.html

{% extends 'core/base.html' %}

{% block title %}Login{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6 col-lg-4">
        <div class="card">
            <div class="card-body">
                <h2 class="card-title text-center">Login</h2>

                <form method="post">
                    {% csrf_token %}

                    {% if form.non_field_errors %}
                        <div class="alert alert-danger">
                            {{ form.non_field_errors }}
                        </div>
                    {% endif %}

                    {% for field in form %}
                        <div class="mb-3">
                            {{ field.label_tag }}
                            {{ field }}

                            {% if field.help_text %}
                                <div class="form-text">
                                    {{ field.help_text }}
                                </div>
                            {% endif %}

                            {% if field.errors %}
                                <div class="text-danger">
                                    {{ field.errors }}
                                </div>
                            {% endif %}
                        </div>
                    {% endfor %}

                    <div class="d-grid">
                        <button type="submit" class="btn btn-primary">
                            Login
                        </button>
                    </div>
                </form>

                <div class="mt-3 text-center">
                    <p>
                        <a href="{% url 'password_reset' %}">
                            Forgot password?
                        </a>
                    </p>
                    <p>
                        Don't have an account?
                        <a href="{% url 'register' %}">Register here</a>
                    </p>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

Design Notes

This pattern repeats cleanly for password reset and registration pages.


Authenticated Page Example: Profile

The profile page shows how authenticated data is surfaced safely and readably.

Full Listing: accounts/profile.html

{% extends 'core/base.html' %}

{% block title %}Profile{% endblock %}

{% block content %}
<div class="row">
    <div class="col-12">
        <h2>Welcome to your profile, {{ user.username }}!</h2>

        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Your Information</h5>

                <p><strong>Username:</strong> {{ user.username }}</p>
                <p><strong>Email:</strong> {{ user.email }}</p>
                <p><strong>Member since:</strong> {{ user.date_joined|date:"F j, Y" }}</p>
                <p><strong>Last login:</strong> {{ user.last_login|date:"F j, Y, g:i a" }}</p>
                <p>
                    <strong>Staff status:</strong>
                    {% if user.is_staff %}Yes{% else %}No{% endif %}
                </p>
                <p>
                    <strong>Superuser status:</strong>
                    {% if user.is_superuser %}Yes{% else %}No{% endif %}
                </p>
            </div>
        </div>

        <div class="mt-3">
            <a href="{% url 'index' %}" class="btn btn-secondary">
                Back to Home
            </a>
            <form method="post" action="{% url 'logout' %}" style="display: inline;">
                {% csrf_token %}
                <button type="submit" class="btn btn-danger ms-2">Logout</button>
            </form>
        </div>
    </div>
</div>
{% endblock %}

Why This Matters

This page is intentionally simple:

In later tutorials, this page becomes the obvious place to surface subscription status, billing links, and feature access.


Password Reset Templates (Brief Note)

The remaining password reset templates (password_reset_email.html, password_reset_confirm.html, etc.) follow the same structural patterns:

They are intentionally thin wrappers around Django’s built-in password reset views, which is exactly what you want for security-sensitive flows.


Closing Note on Templates

These templates are not flashy. They are:

That combination is far more valuable in a SaaS foundation than clever abstractions or premature design systems.


Step 5: Testing and Local Verification

Create an admin user:

python manage.py createsuperuser

Run the app:

python manage.py runserver

Verify:

At this point, the app is fully runnable and coherent.


Production Notes (Preview Only)

Deployment, scaling, and billing are covered later. For now, keep these principles in mind:


Final Thoughts

This tutorial intentionally avoids shortcuts. The goal is not to impress with cleverness, but to create a boring, reliable SaaS foundation that survives growth.

In the next tutorials, we’ll layer on:

Start free. Build trust. Monetize later.

That’s how SaaS actually works.