Django Tutorial: Build a Real Web App (Not a To-Do List)
Build a real web application with Django. Covers project setup, models, views, templates, authentication, forms, and deployment from scratch.
Every Django tutorial builds a to-do list or a poll app. This one doesn't. We're going to build a link bookmarking app -- something you'd actually use -- where users can save links, organize them into collections, and share collections publicly. Along the way, you'll learn every core Django concept.
Django is a Python web framework that follows the "batteries included" philosophy. It gives you an ORM, an admin panel, authentication, form handling, URL routing, and templating out of the box. That might sound like a lot, but it means you spend time building features instead of wiring up infrastructure.
Why Django
Before we write code, here's why Django is worth learning:
- Speed of development: Features that take days in other frameworks take hours in Django. The admin panel alone saves weeks of work.
- Security defaults: CSRF protection, SQL injection prevention, XSS protection, and clickjacking protection are built in and enabled by default.
- ORM: Write Python, get SQL. Migrations are handled automatically.
- Scale: Instagram serves 2 billion+ users on Django. It scales.
- Ecosystem: Thousands of packages for payments (Stripe), search (Elasticsearch), REST APIs (DRF), and more.
Project Setup
Install Django
# Create a virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install Django
pip install django
# Verify
python -m django --version
Create the Project
django-admin startproject bookmarks_project .
The . at the end creates the project in the current directory (no extra nested folder).
Your structure:
bookmarks_project/
__init__.py
settings.py # Configuration
urls.py # Root URL routing
asgi.py # ASGI entry point
wsgi.py # WSGI entry point
manage.py # CLI tool for everything
Create the App
Django projects contain apps. An app is a module that does one thing.
python manage.py startapp links
Register it in settings.py:
# bookmarks_project/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'links', # Add your app here
]
Run the Development Server
python manage.py runserver
Visit http://127.0.0.1:8000/ -- you should see the Django welcome page. You have a working web application.
Models: Defining Your Data
Models are Python classes that map to database tables. Django's ORM handles the SQL.
# links/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
class Collection(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE,
related_name='collections')
is_public = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
return self.name
def save(self, args, *kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(args, *kwargs)
class Link(models.Model):
url = models.URLField(max_length=2000)
title = models.CharField(max_length=300)
description = models.TextField(blank=True)
collection = models.ForeignKey(Collection, on_delete=models.CASCADE,
related_name='links')
added_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
Migrations: Syncing Models to the Database
python manage.py makemigrations # Generate migration files
python manage.py migrate # Apply them to the database
Every time you change a model, run both commands. Django tracks changes and generates the SQL automatically.
Key Model Field Types
| Field | Purpose |
|---|---|
CharField(max_length=N) | Short text with a maximum length |
TextField() | Unlimited text |
IntegerField() | Whole numbers |
BooleanField() | True/False |
DateTimeField() | Date + time |
URLField() | URL with validation |
SlugField() | URL-friendly string |
ForeignKey() | Relationship to another model |
ManyToManyField() | Many-to-many relationship |
Querying with the ORM
# Create
collection = Collection.objects.create(
name="Python Resources",
owner=user,
is_public=True
)
# Read
all_collections = Collection.objects.all()
public_collections = Collection.objects.filter(is_public=True)
my_collection = Collection.objects.get(slug="python-resources")
# Update
my_collection.description = "Useful Python links"
my_collection.save()
# Delete
my_collection.delete()
# Chaining
recent_public = (Collection.objects
.filter(is_public=True)
.order_by('-created_at')[:10])
# Related objects
links_in_collection = my_collection.links.all()
user_collections = user.collections.count()
The Admin Panel
Django's admin is legendary. Register your models and you get a full CRUD interface for free.
# links/admin.py
from django.contrib import admin
from .models import Collection, Link
@admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'owner', 'is_public', 'created_at']
list_filter = ['is_public', 'created_at']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
@admin.register(Link)
class LinkAdmin(admin.ModelAdmin):
list_display = ['title', 'url', 'collection', 'added_by', 'created_at']
list_filter = ['collection', 'created_at']
search_fields = ['title', 'url', 'description']
Create a superuser to access the admin:
python manage.py createsuperuser
Visit http://127.0.0.1:8000/admin/ and log in. You can now create, edit, and delete collections and links through a polished UI. For many internal tools, the admin panel is the entire product.
URL Routing
Django routes URLs to views. You define patterns in urls.py files.
# bookmarks_project/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('links.urls')),
]
# links/urls.py
from django.urls import path
from . import views
app_name = 'links'
urlpatterns = [
path('', views.home, name='home'),
path('collections/', views.collection_list, name='collection_list'),
path('collections/new/', views.collection_create, name='collection_create'),
path('collections/<slug:slug>/', views.collection_detail,
name='collection_detail'),
path('collections/<slug:slug>/add/', views.link_add, name='link_add'),
path('explore/', views.explore, name='explore'),
]
app_name enables namespaced URLs, so you can reference them as links:home in templates and code.
Views: Handling Requests
Views are functions (or classes) that receive a request and return a response. Let's build them all.
# links/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .models import Collection, Link
from .forms import CollectionForm, LinkForm
def home(request):
"""Landing page."""
if request.user.is_authenticated:
collections = Collection.objects.filter(owner=request.user)
return render(request, 'links/home.html', {
'collections': collections
})
return render(request, 'links/landing.html')
def explore(request):
"""Browse public collections."""
collections = Collection.objects.filter(is_public=True)
return render(request, 'links/explore.html', {
'collections': collections
})
def collection_detail(request, slug):
"""View a collection and its links."""
collection = get_object_or_404(Collection, slug=slug)
# Only the owner can see private collections
if not collection.is_public and collection.owner != request.user:
return redirect('links:home')
links = collection.links.all()
return render(request, 'links/collection_detail.html', {
'collection': collection,
'links': links,
})
@login_required
def collection_list(request):
"""List the current user's collections."""
collections = Collection.objects.filter(owner=request.user)
return render(request, 'links/collection_list.html', {
'collections': collections
})
@login_required
def collection_create(request):
"""Create a new collection."""
if request.method == 'POST':
form = CollectionForm(request.POST)
if form.is_valid():
collection = form.save(commit=False)
collection.owner = request.user
collection.save()
messages.success(request, f'Collection "{collection.name}" created.')
return redirect('links:collection_detail', slug=collection.slug)
else:
form = CollectionForm()
return render(request, 'links/collection_form.html', {
'form': form,
'title': 'New Collection'
})
@login_required
def link_add(request, slug):
"""Add a link to a collection."""
collection = get_object_or_404(Collection, slug=slug, owner=request.user)
if request.method == 'POST':
form = LinkForm(request.POST)
if form.is_valid():
link = form.save(commit=False)
link.collection = collection
link.added_by = request.user
link.save()
messages.success(request, f'Link added to {collection.name}.')
return redirect('links:collection_detail', slug=slug)
else:
form = LinkForm()
return render(request, 'links/link_form.html', {
'form': form,
'collection': collection,
})
Key patterns here:
get_object_or_404: fetch an object or return 404. Never manually checkif not exists.@login_required: redirects anonymous users to the login page.commit=False: save the form to get a model instance without hitting the database, so you can add extra fields.- The
messagesframework: flash messages that display once and disappear.
Forms
Django forms handle validation, rendering, and security (CSRF) automatically.
# links/forms.py
from django import forms
from .models import Collection, Link
class CollectionForm(forms.ModelForm):
class Meta:
model = Collection
fields = ['name', 'description', 'is_public']
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-input',
'placeholder': 'Collection name'
}),
'description': forms.Textarea(attrs={
'class': 'form-textarea',
'rows': 3,
'placeholder': 'Optional description'
}),
}
class LinkForm(forms.ModelForm):
class Meta:
model = Link
fields = ['url', 'title', 'description']
widgets = {
'url': forms.URLInput(attrs={
'class': 'form-input',
'placeholder': 'https://...'
}),
'title': forms.TextInput(attrs={
'class': 'form-input',
'placeholder': 'Link title'
}),
'description': forms.Textarea(attrs={
'class': 'form-textarea',
'rows': 2,
'placeholder': 'Why is this link useful?'
}),
}
ModelForm generates form fields from your model. It handles validation (URL format, required fields, max length) automatically. You customize with widgets for HTML attributes and fields to control which model fields appear.
Templates
Create the template directory structure:
links/templates/links/
base.html
landing.html
home.html
explore.html
collection_list.html
collection_detail.html
collection_form.html
link_form.html
Base Template
<!-- links/templates/links/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 %}Bookmarks{% endblock %}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; line-height: 1.6;
max-width: 800px; margin: 0 auto; padding: 20px; }
nav { display: flex; gap: 16px; padding: 16px 0;
border-bottom: 1px solid #e5e5e5; margin-bottom: 24px; }
nav a { text-decoration: none; color: #2563eb; }
.btn { display: inline-block; padding: 8px 16px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;
border: none; cursor: pointer; }
.form-input, .form-textarea { width: 100%; padding: 8px 12px;
border: 1px solid #d1d5db; border-radius: 6px;
margin-bottom: 12px; }
.message { padding: 12px; border-radius: 6px; margin-bottom: 16px;
background: #dcfce7; color: #166534; }
</style>
</head>
<body>
<nav>
<a href="{% url 'links:home' %}">Home</a>
<a href="{% url 'links:explore' %}">Explore</a>
{% if user.is_authenticated %}
<a href="{% url 'links:collection_list' %}">My Collections</a>
<a href="{% url 'links:collection_create' %}">New Collection</a>
<a href="{% url 'logout' %}">Logout</a>
{% else %}
<a href="{% url 'login' %}">Login</a>
{% endif %}
</nav>
{% for message in messages %}
<div class="message">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
</body>
</html>
Collection Detail Template
<!-- links/templates/links/collection_detail.html -->
{% extends "links/base.html" %}
{% block title %}{{ collection.name }}{% endblock %}
{% block content %}
<h1>{{ collection.name }}</h1>
{% if collection.description %}
<p>{{ collection.description }}</p>
{% endif %}
<p>
By {{ collection.owner.username }}
{% if collection.is_public %}(Public){% else %}(Private){% endif %}
</p>
{% if user == collection.owner %}
<a href="{% url 'links:link_add' collection.slug %}" class="btn">
Add Link
</a>
{% endif %}
<div style="margin-top: 24px;">
{% for link in links %}
<div style="padding: 16px 0; border-bottom: 1px solid #e5e5e5;">
<a href="{{ link.url }}" target="_blank" rel="noopener">
<strong>{{ link.title }}</strong>
</a>
<br>
<small style="color: #6b7280;">{{ link.url|truncatechars:60 }}</small>
{% if link.description %}
<p>{{ link.description }}</p>
{% endif %}
</div>
{% empty %}
<p>No links yet. Add some!</p>
{% endfor %}
</div>
{% endblock %}
Form Template (Reusable)
<!-- links/templates/links/collection_form.html -->
{% extends "links/base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<form method="post">
{% csrf_token %}
{% for field in form %}
<div style="margin-bottom: 16px;">
<label for="{{ field.id_for_label }}">
<strong>{{ field.label }}</strong>
</label>
{{ field }}
{% if field.errors %}
<p style="color: red;">{{ field.errors.0 }}</p>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn">Save</button>
</form>
{% endblock %}
{% csrf_token %} is required on every POST form. It prevents cross-site request forgery attacks. Django will reject the form without it.
Authentication
Django has authentication built in. You just need to wire up the URLs and templates.
# bookmarks_project/urls.py
from django.contrib import admin
from django.urls import path, include
from django.contrib.auth import views as auth_views
urlpatterns = [
path('admin/', admin.site.urls),
path('login/', auth_views.LoginView.as_view(
template_name='links/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(
next_page='/'), name='logout'),
path('', include('links.urls')),
]
Add to settings.py:
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
Create the login template:
<!-- links/templates/links/login.html -->
{% extends "links/base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<h1>Login</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Log In</button>
</form>
{% endblock %}
That's it. Full authentication with session management, password hashing, and CSRF protection. Django handles all of it.
For user registration, you'd create a view with Django's UserCreationForm:
from django.contrib.auth.forms import UserCreationForm
def signup(request):
if request.method == 'POST':
form = UserCreationForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
return redirect('links:home')
else:
form = UserCreationForm()
return render(request, 'links/signup.html', {'form': form})
Deploying Your Django App
Django apps are typically deployed with:
- Gunicorn as the WSGI server (replaces Django's dev server)
- PostgreSQL as the database (replaces SQLite)
- Nginx or a cloud load balancer in front
- Static files served separately (WhiteNoise for simplicity, S3/CDN for scale)
Preparing for Production
# settings.py changes for production
import os
DEBUG = False # NEVER True in production
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': '5432',
}
}
# Static files
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
Quick Deploy with Railway/Render
Modern platforms make deployment simple:
# requirements.txt
django==5.1
gunicorn==23.0.0
psycopg2-binary==2.9.9
whitenoise==6.7.0
# Procfile (for Railway/Render)
web: gunicorn bookmarks_project.wsgi
Add WhiteNoise to middleware for static file serving:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Add this
# ... rest of middleware
]
Then push to Git and connect to Railway, Render, or Fly.io. They auto-detect Django and deploy it.
Django vs Other Frameworks
| Django | Flask | FastAPI | Express | |
|---|---|---|---|---|
| Language | Python | Python | Python | JavaScript |
| Philosophy | Batteries included | Micro (bring your own) | Modern, async | Minimal |
| ORM | Built-in | SQLAlchemy (add-on) | SQLAlchemy (add-on) | Prisma/Sequelize |
| Admin | Built-in | Flask-Admin (add-on) | None | None |
| Auth | Built-in | Flask-Login (add-on) | Roll your own | Passport.js |
| Best for | Full web apps | APIs, microservices | APIs, async | APIs, real-time |
| Learning curve | Medium | Low | Low | Low |
Common Django Patterns
Class-Based Views (Alternative Approach)
from django.views.generic import ListView, DetailView, CreateView
class CollectionListView(ListView):
model = Collection
template_name = 'links/collection_list.html'
context_object_name = 'collections'
def get_queryset(self):
return Collection.objects.filter(owner=self.request.user)
Class-based views reduce boilerplate for standard CRUD operations. Function-based views are better when you need custom logic. Use both -- they're not mutually exclusive.
Management Commands
# links/management/commands/cleanup_empty.py
from django.core.management.base import BaseCommand
from links.models import Collection
class Command(BaseCommand):
help = 'Delete collections with no links'
def handle(self, args, *options):
empty = Collection.objects.filter(links__isnull=True)
count = empty.count()
empty.delete()
self.stdout.write(f'Deleted {count} empty collections')
python manage.py cleanup_empty
Signals (React to Events)
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_default_collection(sender, instance, created, **kwargs):
if created:
Collection.objects.create(
name="My Bookmarks",
owner=instance,
)
Every new user automatically gets a default collection.
Next Steps
- Django REST Framework: Build APIs that mobile apps and SPAs can consume
- Testing: Django's test framework is excellent --
TestCase,Client, and factory patterns - Caching: Django's cache framework supports Redis, Memcached, and database caching
- Celery: Background task processing for emails, data processing, etc.
- Channels: WebSocket support for real-time features
For more web development tutorials, framework guides, and programming content, check out CodeUp.