Build a Bookmark Manager with Django
Build a full CRUD bookmark manager using Django, MySQL, and Docker.
Introduction
30 Second Summary
Everyone has bookmarks scattered across browsers, devices, and note apps. A dedicated bookmark manager gives you one place to save, organize, and find your links whenever you need them.
In this project, you will build a Bookmark Manager web app called "Link Vault" using Django connected to a MySQL database running inside a Docker container.
What You'll Build
A full-stack Django web application with complete CRUD operations, connected to a MySQL 8.0 database managed by Docker Compose.
By the end of this project, you'll have:
- A Django project with a bookmarks app containing models, views, templates, and URL routing connected to MySQL in Docker.
- A working web interface to add, view, edit, and delete bookmarks organized by category.
- Django Admin configured to manage your bookmarks and categories from a dashboard.
- Secret Mission: Add a search feature to filter bookmarks by title or URL.
Are there any prerequisites?
This project assumes basic Python knowledge, familiarity with HTML/CSS/JS fundamentals, and an understanding of Docker basics (containers, compose, and volumes). You should also have VS Code, Docker Desktop, and Python installed on your machine.
Set Up the Project and Database
Your development environment is ready with Python, Django, and mysqlclient installed. Now you need a database for your bookmark manager to store data in.
In this step, you'll spin up a MySQL database inside a Docker container using Docker Compose, then create the Django project and wire it up to that database.
In this step, get ready to:
- Create a Docker Compose file to run MySQL 8.0.
- Create the Django project and configure it to connect to MySQL.
- Verify the development server starts and connects to the database.
Create and start the MySQL database container
Your bookmark manager needs a place to store bookmarks and categories. A MySQL database running inside a Docker container gives you a production-like setup without installing MySQL directly on your machine.
- In VS Code, create a new file called docker-compose.yml in the root of your project folder (the same level where your virtual environment lives).
- Paste the following configuration into the file:
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: linkvault
MYSQL_USER: linkvault_user
MYSQL_PASSWORD: linkvault_pass
ports:
- "3306:3306"
volumes:
- linkvault-mysql-data:/var/lib/mysql
volumes:
linkvault-mysql-data:
What does this configuration do?
- image: mysql:8.0 pulls the official MySQL 8.0 image from Docker Hub.
- The environment section creates a database called linkvault with a dedicated user and password on first startup.
- ports maps port 3306 inside the container to port 3306 on your machine, so Django can connect.
- volumes creates a named volume called linkvault-mysql-data that persists your data even if the container is stopped or removed.
- Save docker-compose.yml.
- Confirm the file exists by running:
ls docker-compose.yml
You should see docker-compose.yml listed in the output.
- Start the MySQL container by running this command:
docker compose up -d
What does this command do?
The -d flag runs the container in detached mode, meaning it runs in the background and gives you your terminal back. Docker pulls the MySQL image (this may take a minute the first time), creates the container, and starts it.
- Verify the container is running by running:
docker compose ps
You should see a container listed with a status of running or Up.
Container not starting?
- Make sure Docker Desktop is running. Check your system tray or taskbar for the Docker icon.
- If port 3306 is already in use, you may have another MySQL instance running. Stop it or change the host port in docker-compose.yml.
- help me troubleshoot my Docker container not starting
✔️ Awesome, I've got everything!
Great. Double check you've saved docker-compose.yml and your MySQL container is running.
ⓧ I'd like to double check the full code
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: linkvault
MYSQL_USER: linkvault_user
MYSQL_PASSWORD: linkvault_pass
ports:
- "3306:3306"
volumes:
- linkvault-mysql-data:/var/lib/mysql
volumes:
linkvault-mysql-data:
Create the Django project and configure MySQL
With MySQL running, you now need a Django project that knows how to talk to it. The django-admin startproject command scaffolds the project structure for you.
- Create the Django project in your current directory by running this command:
django-admin startproject linkvault .
Why the dot at the end?
The . tells Django to create the project files in your current folder instead of creating an extra nested directory. This keeps your project structure flat and tidy.
- Confirm the project was created by running:
ls
You should see manage.py and a linkvault/ folder alongside your docker-compose.yml.
Seeing a 'conflicting files' error?
- This happens if a linkvault folder already exists. Make sure you're running the command in the correct project directory and that you haven't run it before.
- help me fix the django-admin startproject conflict error
Now you need to tell Django to use your MySQL container instead of the default SQLite database.
- Open linkvault/settings.py in VS Code.
- Find the DATABASES section. It looks like this:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
- Replace the entire DATABASES block with this MySQL configuration:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "linkvault",
"USER": "linkvault_user",
"PASSWORD": "linkvault_pass",
"HOST": "127.0.0.1",
"PORT": "3306",
}
}
What do these settings mean?
- ENGINE tells Django to use the MySQL database backend (django.db.backends.mysql).
- NAME, USER, and PASSWORD match the values you set in docker-compose.yml.
- HOST is 127.0.0.1 because the container maps port 3306 to your local machine.
- PORT matches the port exposed in your Docker Compose file.
- Save linkvault/settings.py.
Now test that Django can connect to your MySQL database.
- Start the Django development server by running:
python manage.py runserver
You should see output that includes a line like Starting development server at http://127.0.0.1:8000/.
What about the 'unapplied migrations' warning?
Django will show a warning about unapplied migrations. This is normal and expected at this stage. You'll run migrations in the next step after defining your models.
- Open your browser and visit http://127.0.0.1:8000/.
You should see the Django welcome page with a rocket icon confirming the install worked.
- Press Ctrl+C in your terminal to stop the development server.
Seeing a database connection error?
- Run docker compose ps to check that your MySQL container is still running.
- Double check that the NAME, USER, and PASSWORD values in settings.py match your docker-compose.yml exactly.
- Make sure HOST is set to 127.0.0.1 (not localhost). On Windows, localhost can cause connection issues with MySQL.
- help me debug my Django MySQL connection error
✔️ Awesome, I've got everything!
Great. Make sure you've saved linkvault/settings.py and that the server started without database errors. Your SECRET_KEY will look different from the reference below because Django generates a unique one for each project.
ⓧ I'd like to double check the full code
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-change-this-in-production"
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "linkvault.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "linkvault.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "linkvault",
"USER": "linkvault_user",
"PASSWORD": "linkvault_pass",
"HOST": "127.0.0.1",
"PORT": "3306",
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
Your database is running and Django is connected to it. Next up, you'll define the data models that tell Django what a bookmark and a category look like.
Define Your Data Models
Your Django project is connected to MySQL and the development server runs without errors. The next step is designing the structure of the data your app will store.
In Django, you define your database tables as Python classes called models. Each model maps to a table, and each field on the model maps to a column. In this step, you'll create the bookmarks app, define your data models, and configure Django Admin to manage them.
In this step, get ready to:
- Create the bookmarks app.
- Define Category and Bookmark models.
- Register models with Django Admin, run migrations, and create a superuser.
Create the bookmarks app
Django organizes code into apps. Each app handles one piece of functionality. Your bookmark manager needs a bookmarks app to hold its models, views, and templates.
- Generate the bookmarks app by running this command in your terminal (from inside the linkvault project folder where manage.py lives):
python manage.py startapp bookmarks
- Confirm the app was created by running:
ls bookmarks
You should see __init__.py, admin.py, apps.py, models.py, tests.py, views.py, and a migrations folder in the output.
Command not found or no such file?
Make sure your virtual environment is active. You should see its name in parentheses in your terminal prompt.
Also confirm you're in the same directory as manage.py. Run ls and check that manage.py appears in the listing.
Help me troubleshoot the startapp command
Define the Category and Bookmark models
Models are the single source of truth for your data. Django uses them to create database tables and to validate data before saving it.
Your bookmark manager needs two models: a Category to organize bookmarks into groups, and a Bookmark to store each saved link.
- Open bookmarks/models.py in VS Code.
- Delete the existing contents and paste the following code to define the Category model:
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
class Meta:
verbose_name_plural = "categories"
def __str__(self):
return self.name
What does this code do?
- Category inherits from models.Model, which gives it all the database powers Django provides (saving, querying, deleting).
- CharField(max_length=100) creates a text column with a 100-character limit.
- verbose_name_plural tells Django Admin to display "categories" instead of the default "categorys".
- __str__ controls how a category appears in Django Admin and in dropdowns.
You won't see a change yet. The next chunk adds the Bookmark model that references this Category.
- Add the following Bookmark model below the Category class in bookmarks/models.py:
class Bookmark(models.Model):
title = models.CharField(max_length=200)
url = models.URLField()
description = models.TextField(blank=True)
category = models.ForeignKey(
Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="bookmarks"
)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
What does this code do?
- URLField() validates that the input is a properly formatted URL before saving.
- TextField(blank=True) allows longer text and makes the field optional in forms.
- ForeignKey(Category, on_delete=models.SET_NULL) links each bookmark to a category. If a category is deleted, the bookmark stays but its category becomes empty.
- auto_now_add=True automatically records the date and time when the bookmark is created.
- ordering = ["-created_at"] displays the newest bookmarks first by default.
- Save bookmarks/models.py.
You won't see a visible change yet. In the next substep, you'll run migrations to turn these models into real MySQL tables.
Seeing a red underline in VS Code?
If VS Code shows an import error on from django.db import models, make sure your VS Code Python interpreter is set to your virtual environment. Click the Python version in the bottom-left status bar and select the interpreter inside your virtual environment folder.
Help me fix import errors in my models file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to the next substep.
ⓧ I'd like to double check the full code
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
class Meta:
verbose_name_plural = "categories"
def __str__(self):
return self.name
class Bookmark(models.Model):
title = models.CharField(max_length=200)
url = models.URLField()
description = models.TextField(blank=True)
category = models.ForeignKey(
Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="bookmarks"
)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
Register models and run migrations
Django Admin gives you a web interface to manage your data without writing any frontend code. To use it, you register your models in admin.py.
- Open bookmarks/admin.py in VS Code.
- Replace the existing contents with the following code to register the Category model:
from django.contrib import admin
from .models import Category, Bookmark
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["name"]
What does this code do?
- @admin.register(Category) tells Django to show the Category model in the admin interface.
- list_display controls which columns appear in the admin list view.
You won't see a change yet. The next chunk registers the Bookmark model with more detailed admin options.
- Add the following BookmarkAdmin class below the CategoryAdmin in bookmarks/admin.py:
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ["title", "url", "category", "is_read", "created_at"]
list_filter = ["category", "is_read"]
search_fields = ["title", "url"]
What does this code do?
- list_display shows the title, URL, category, read status, and creation date as columns in the bookmark list.
- list_filter adds a sidebar in Django Admin to filter bookmarks by category or read status.
- search_fields adds a search box that searches across bookmark titles and URLs.
- Save bookmarks/admin.py.
Seeing import errors?
If you see a red underline on from .models import Category, Bookmark, that's expected until we register the app in the next step. VS Code doesn't know the app exists yet.
Help me fix import errors in admin.py
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then continue below.
ⓧ I'd like to double check the full code
from django.contrib import admin
from .models import Category, Bookmark
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["name"]
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ["title", "url", "category", "is_read", "created_at"]
list_filter = ["category", "is_read"]
search_fields = ["title", "url"]
Now you need to tell Django that the bookmarks app exists. Without this, Django won't detect your models or run migrations for them.
- Open linkvault/settings.py.
- Find the INSTALLED_APPS list. It currently looks like this:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
- Add "bookmarks", as the last item in the list, so it looks like this:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"bookmarks",
]
- Save linkvault/settings.py.
With the app registered, Django can now detect your models. Migrations translate your Python model code into SQL commands that create the actual tables in MySQL.
- Generate and apply the migrations by running these commands:
python manage.py makemigrations
python manage.py migrate
What do these commands do?
- makemigrations reads your models and generates migration files that describe the database changes needed.
- migrate applies those migration files to your MySQL database, creating the actual tables.
You should see output mentioning the creation of the bookmarks_category and bookmarks_bookmark tables, along with Django's built-in tables for auth and sessions.
Seeing a connection refused error?
Make sure your MySQL Docker container is still running. Run docker compose ps to check. If it's stopped, run docker compose up -d to start it again.
If you see "No changes detected" from makemigrations, double check that "bookmarks" is listed in INSTALLED_APPS in settings.py.
Help me troubleshoot my migration errors
Finally, create a superuser account so you can log into Django Admin. This command will prompt you for a username, email, and password.
- Create your admin account by running:
python manage.py createsuperuser
What will this command ask?
The command prompts for a username, email address, and password (entered twice for confirmation). Choose something you'll remember. For a local project, simple values like admin / admin@example.com / password123 work fine.
Django may warn you that the password is too common. Type y to confirm and proceed anyway.
Now let's verify everything works by logging into Django Admin and adding some test data.
- Start the development server by running:
python manage.py runserver
- Open your browser and go to http://127.0.0.1:8000/admin/.
- Log in with the superuser credentials you just created.
- Click Categorys (Django shows your corrected plural "categories" in the sidebar) and click Add.
- Enter Development as the name and click Save.
- Go back to the home page and click Bookmarks, then click Add.
- Fill in a title (e.g. Django Docs), a URL (e.g. https://docs.djangoproject.com), select the Development category, and click Save.
You should see your bookmark listed in the admin with all the columns you defined in list_display: title, URL, category, read status, and creation date.
Don't see Categories or Bookmarks in the admin?
Confirm that "bookmarks" is in your INSTALLED_APPS list in settings.py and that you've saved the file.
Check that your admin.py uses the @admin.register() decorator and that the class names match exactly. Restart the development server after making changes.
Help me figure out why my models aren't showing in Django Admin
Your data models are in place and Django Admin confirms everything is wired up correctly. Next up, you'll build views and URL routing so users can browse bookmarks without needing the admin interface.
Build Views and URL Routing
Your data models are defined and your Django Admin is ready to manage bookmarks and categories.
Now you need to build the public-facing part of your app. When a user visits a URL like /bookmarks/, Django needs to know which Python function to run and what HTML to send back. In this step, you will create views, URL patterns, and a form class that together make your bookmarks accessible through a browser.
In this step, get ready to:
- Create function-based views for listing, viewing, and adding bookmarks.
- Set up URL patterns in the bookmarks app and connect them to the project.
- Create a Django form class for the Bookmark model.
Create function-based views for your bookmarks
In Django's MTV (Model-Template-View) architecture, a view is a Python function that receives a web request and returns a web response. Each page in your app needs its own view function.
- In VS Code, open the file bookmarks/views.py inside your linkvault project folder.
- Replace all of the existing content with the following code:
from django.shortcuts import render, get_object_or_404, redirect
from .models import Bookmark
from .forms import BookmarkForm
def bookmark_list(request):
# Fetch all bookmarks from the database
bookmarks = Bookmark.objects.all()
return render(request, "bookmarks/bookmark_list.html", {"bookmarks": bookmarks})
def bookmark_detail(request, pk):
# Fetch a single bookmark or return a 404 page
bookmark = get_object_or_404(Bookmark, pk=pk)
return render(request, "bookmarks/bookmark_detail.html", {"bookmark": bookmark})
def bookmark_add(request):
# Handle both displaying the empty form (GET) and saving data (POST)
if request.method == "POST":
form = BookmarkForm(request.POST)
if form.is_valid():
form.save()
return redirect("bookmark_list")
else:
form = BookmarkForm()
return render(request, "bookmarks/bookmark_form.html", {"form": form, "title": "Add Bookmark"})
What does this code do?
You have three view functions, each handling a different page of your app.
- bookmark_list queries the database for all bookmarks and passes them to a template for display.
- bookmark_detail uses get_object_or_404 to find a specific bookmark by its primary key. If no match exists, Django returns a 404 error automatically.
- bookmark_add handles two scenarios. A GET request shows a blank form. A POST request validates the submitted data and saves a new bookmark to the database.
- Save bookmarks/views.py.
Why does the import show an error?
Your editor might underline from .forms import BookmarkForm with a red squiggly line. That is expected because you have not created forms.py yet. You will create it in the last substep and the error will disappear.
Set up URL patterns and connect them to the project
Django uses URL patterns to map browser addresses to view functions. You need a URL configuration file inside the bookmarks app, plus a line in the project-level urls.py to include it.
- Create a new file called urls.py inside the bookmarks folder.
- Add the following code to bookmarks/urls.py:
from django.urls import path
from . import views
urlpatterns = [
# /bookmarks/ shows the full list
path("", views.bookmark_list, name="bookmark_list"),
# /bookmarks/1/ shows bookmark with pk=1
path("<int:pk>/", views.bookmark_detail, name="bookmark_detail"),
# /bookmarks/add/ shows the form to create a new bookmark
path("add/", views.bookmark_add, name="bookmark_add"),
]
What does this code do?
- Each path() call maps a URL pattern to a view function. The name argument gives each route a human-friendly label you can reference in templates later.
- <int:pk>/ is a URL converter. It captures an integer from the URL (like 1 in /bookmarks/1/) and passes it to the view as the pk argument.
- Save bookmarks/urls.py.
Now you need to tell the main project about your app's URLs.
- Open linkvault/urls.py.
- Find this line:
from django.urls import path
- Replace it with this line that adds the include function:
from django.urls import path, include
- Then find the urlpatterns list and add a new path below the admin line, so it looks like this:
urlpatterns = [
path("admin/", admin.site.urls),
path("bookmarks/", include("bookmarks.urls")),
]
What does include() do?
The include() function delegates URL matching to the bookmarks app. Any request starting with /bookmarks/ gets handed off to bookmarks/urls.py where the remaining portion of the URL is matched against the app's patterns.
- Save linkvault/urls.py.
Seeing an import error for bookmarks.urls?
- Make sure you created the file as urls.py inside the bookmarks folder, not inside linkvault.
- Check that the string in include() is exactly "bookmarks.urls" with a dot, not a slash.
- help me fix my URL configuration
Create a Django form class for the Bookmark model
Django forms handle rendering HTML form fields and validating user input. A ModelForm automatically generates form fields based on your model's fields, saving you from writing repetitive HTML.
- Create a new file called forms.py inside the bookmarks folder.
- Add the following code to bookmarks/forms.py:
from django import forms
from .models import Bookmark
class BookmarkForm(forms.ModelForm):
class Meta:
# Link this form to the Bookmark model
model = Bookmark
# Only include these fields in the form
fields = ["title", "url", "description", "category", "is_read"]
# Customize how each field renders in HTML
widgets = {
"title": forms.TextInput(attrs={"placeholder": "Enter bookmark title"}),
"url": forms.URLInput(attrs={"placeholder": "https://example.com"}),
"description": forms.Textarea(attrs={"rows": 3, "placeholder": "Optional description"}),
}
What does this code do?
- BookmarkForm inherits from forms.ModelForm, which auto-generates form fields from your Bookmark model's fields.
- The fields list controls which model fields appear in the form. You excluded created_at because Django sets it automatically.
- The widgets dictionary customizes the HTML output. Placeholders give users a hint about what to type in each field.
- Save bookmarks/forms.py.
- Confirm the red underline in views.py is now gone. The import resolves because forms.py exists.
Now verify everything is wired up by starting the development server.
- Start the Django development server by running:
py manage.py runserver
- Open your browser and navigate to http://127.0.0.1:8000/bookmarks/.
What should I see?
You will see a Django error page titled TemplateDoesNotExist. This is expected and correct. It means Django successfully matched the URL to your view and tried to render a template, but you have not created the template files yet. You will build those in the next step.
Seeing a different error?
- If you see a "Page not found (404)" error, check that the URL you typed includes the trailing slash: /bookmarks/ not /bookmarks.
- If you see "ModuleNotFoundError", double check that bookmarks/urls.py exists and that linkvault/urls.py uses include("bookmarks.urls") with a dot separator.
- If you see "ImportError" mentioning BookmarkForm, make sure you saved bookmarks/forms.py and that its filename is exactly forms.py.
- help me debug my Django URL routing
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: bookmarks/views.py, bookmarks/urls.py, and bookmarks/forms.py.
ⓧ I'd like to double check the full code
from django.shortcuts import render, get_object_or_404, redirect
from .models import Bookmark
from .forms import BookmarkForm
def bookmark_list(request):
bookmarks = Bookmark.objects.all()
return render(request, "bookmarks/bookmark_list.html", {"bookmarks": bookmarks})
def bookmark_detail(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
return render(request, "bookmarks/bookmark_detail.html", {"bookmark": bookmark})
def bookmark_add(request):
if request.method == "POST":
form = BookmarkForm(request.POST)
if form.is_valid():
form.save()
return redirect("bookmark_list")
else:
form = BookmarkForm()
return render(request, "bookmarks/bookmark_form.html", {"form": form, "title": "Add Bookmark"})
from django.urls import path
from . import views
urlpatterns = [
path("", views.bookmark_list, name="bookmark_list"),
path("<int:pk>/", views.bookmark_detail, name="bookmark_detail"),
path("add/", views.bookmark_add, name="bookmark_add"),
]
from django import forms
from .models import Bookmark
class BookmarkForm(forms.ModelForm):
class Meta:
model = Bookmark
fields = ["title", "url", "description", "category", "is_read"]
widgets = {
"title": forms.TextInput(attrs={"placeholder": "Enter bookmark title"}),
"url": forms.URLInput(attrs={"placeholder": "https://example.com"}),
"description": forms.Textarea(attrs={"rows": 3, "placeholder": "Optional description"}),
}
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("bookmarks/", include("bookmarks.urls")),
]
Your views, URLs, and form are all wired up. Django can match requests to the right functions and your form knows how to validate bookmark data. Next up, you will create the HTML templates that turn your view data into pages a user can actually see and interact with.
Create Templates and Add Full CRUD
Your views and URL routing are connected, but visiting http://127.0.0.1:8000/bookmarks/ still looks rough because Django has no templates to render.
In this step, you will create a base template with shared styling, build page-specific templates for the bookmark list, detail, and form views, then add edit and delete functionality to complete the full CRUD cycle.
In this step, get ready to:
- Create a base template with HTML structure and CSS styling.
- Create templates for the bookmark list, detail, and add pages.
- Add edit and delete views, URLs, and templates to complete CRUD.
Create the base template
Django uses template inheritance to avoid repeating the same HTML on every page. You create one base template with the shared layout, and each page template extends it with page-specific content.
- Create the templates directory structure by running this command:
macOS
mkdir -p bookmarks/templates/bookmarks
Windows
mkdir bookmarks\templates\bookmarks
Why the nested bookmarks/templates/bookmarks path?
Django looks for templates in each app's templates/ directory. The extra bookmarks/ folder inside prevents naming collisions if another app has a template with the same filename.
- Confirm the folder was created by running:
ls bookmarks/templates/bookmarks
You should see an empty directory (no output or an empty listing). The folder exists and is ready for template files.
- Create a new file called base.html inside bookmarks/templates/bookmarks/ and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Link Vault{% endblock %}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.6; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
header { background: #2c3e50; color: white; padding: 20px; margin-bottom: 30px; }
header h1 { font-size: 1.5rem; }
nav a { color: #ecf0f1; margin-right: 15px; text-decoration: none; }
nav a:hover { text-decoration: underline; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 0.9rem; cursor: pointer; border: none; }
.btn-primary { background: #3498db; color: white; }
.btn-danger { background: #e74c3c; color: white; }
.btn-secondary { background: #95a5a6; color: white; }
.btn:hover { opacity: 0.9; }
form label { display: block; margin-bottom: 5px; font-weight: 600; margin-top: 15px; }
form input, form select, form textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
form button { margin-top: 20px; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.75rem; background: #ecf0f1; }
.badge-read { background: #27ae60; color: white; }
.badge-unread { background: #f39c12; color: white; }
</style>
</head>
<body>
<header>
<div class="container">
<h1>Link Vault</h1>
<nav>
<a href="{% url 'bookmark_list' %}">All Bookmarks</a>
<a href="{% url 'bookmark_add' %}">Add Bookmark</a>
<a href="/admin/">Admin</a>
</nav>
</div>
</header>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
What does this template do?
This base template defines the page shell that every other template inherits.
- The {% block title %} and {% block content %} tags are slots that child templates fill with their own content.
- The <style> section adds CSS for cards, buttons, badges, and form inputs so the app looks polished.
- The <nav> links use Django's {% url %} tag to reference URL pattern names instead of hard-coding paths.
- Save base.html.
Seeing a TemplateDoesNotExist error later?
Make sure your directory path is exactly bookmarks/templates/bookmarks/base.html. A missing level in the nesting causes Django to not find the file.
Also confirm that APP_DIRS is set to True in your settings.py TEMPLATES config (it should be from the default setup).
Help me fix a TemplateDoesNotExist error in my Django project.
Create the page templates
Each page template extends the base and fills in the {% block content %} slot with its own HTML.
- Create a new file called bookmark_list.html inside bookmarks/templates/bookmarks/ and paste this code:
{% extends "bookmarks/base.html" %}
{% block title %}All Bookmarks - Link Vault{% endblock %}
{% block content %}
<h2>Your Bookmarks</h2>
<p style="margin: 15px 0;"><a href="{% url 'bookmark_add' %}" class="btn btn-primary">+ Add Bookmark</a></p>
{% for bookmark in bookmarks %}
<div class="card">
<h3><a href="{% url 'bookmark_detail' bookmark.pk %}">{{ bookmark.title }}</a></h3>
<p><a href="{{ bookmark.url }}" target="_blank">{{ bookmark.url }}</a></p>
<p>
{% if bookmark.category %}<span class="badge">{{ bookmark.category }}</span>{% endif %}
{% if bookmark.is_read %}<span class="badge badge-read">Read</span>{% else %}<span class="badge badge-unread">Unread</span>{% endif %}
</p>
</div>
{% empty %}
<div class="card">
<p>No bookmarks yet. <a href="{% url 'bookmark_add' %}">Add your first one!</a></p>
</div>
{% endfor %}
{% endblock %}
What does this template do?
- The {% for bookmark in bookmarks %} loop iterates over every bookmark passed from the view.
- Each bookmark renders as a card with its title (linked to the detail page), URL, category badge, and read/unread status.
- The {% empty %} block shows a friendly message when there are no bookmarks yet.
- Save bookmark_list.html and refresh http://127.0.0.1:8000/bookmarks/ in your browser.
- You should see a styled page with a dark header reading "Link Vault", navigation links, and either your bookmarks as white cards or a "No bookmarks yet" message.
Page still looks unstyled?
Check that line 1 of bookmark_list.html says {% extends "bookmarks/base.html" %}. A typo in the path means Django skips the base template and renders raw content.
Help me debug why my Django template inheritance isn't working.
- Create a new file called bookmark_detail.html inside bookmarks/templates/bookmarks/ and paste this code:
{% extends "bookmarks/base.html" %}
{% block title %}{{ bookmark.title }} - Link Vault{% endblock %}
{% block content %}
<div class="card">
<h2>{{ bookmark.title }}</h2>
<p><a href="{{ bookmark.url }}" target="_blank">{{ bookmark.url }}</a></p>
{% if bookmark.description %}<p>{{ bookmark.description }}</p>{% endif %}
<p>
{% if bookmark.category %}<span class="badge">{{ bookmark.category }}</span>{% endif %}
{% if bookmark.is_read %}<span class="badge badge-read">Read</span>{% else %}<span class="badge badge-unread">Unread</span>{% endif %}
</p>
</div>
{% endblock %}
What does this template do?
This page shows a single bookmark's full details: title, clickable URL, optional description, and status badges. You will add Edit and Delete buttons here later in this step once those URL patterns exist.
- Save bookmark_detail.html.
- In the browser, click any bookmark title on the list page. You should see a detail card showing that bookmark's title, URL, and badges.
- Create a new file called bookmark_form.html inside bookmarks/templates/bookmarks/ and paste this code:
{% extends "bookmarks/base.html" %}
{% block title %}{{ title }} - Link Vault{% endblock %}
{% block content %}
<div class="card">
<h2>{{ title }}</h2>
<form method="post">
{% csrf_token %}
{% for field in form %}
<div>
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<p style="color: #e74c3c; font-size: 0.85rem;">{{ field.errors.0 }}</p>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'bookmark_list' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}
What does this template do?
- The {% csrf_token %} tag inserts a hidden CSRF token that Django requires on every POST form to prevent cross-site request forgery.
- The {% for field in form %} loop renders each form field with its label, input widget, and any validation errors.
- This template is reused for both adding and editing bookmarks. The {{ title }} variable changes to show "Add Bookmark" or "Edit Bookmark" depending on which view renders it.
- Save bookmark_form.html.
- In the browser, click the + Add Bookmark button on the list page. You should see a styled form with fields for title, URL, description, category, and is_read.
Form page returns a 403 Forbidden?
Make sure {% csrf_token %} appears inside the <form> tag. Django rejects POST requests that are missing the CSRF token.
Help me fix a 403 Forbidden error on my Django form submission.
- Test the add flow: fill in a title and URL, then click Save. You should be redirected back to the bookmark list with your new bookmark showing as a card.
Add edit and delete functionality
The app can create and read bookmarks, but a real bookmark manager also needs update and delete capabilities. You will add two new views, register their URL patterns, then wire up the templates.
- In bookmarks/views.py, find the bookmark_add function. Copy the following bookmark_edit function below it:
def bookmark_edit(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
if request.method == "POST":
form = BookmarkForm(request.POST, instance=bookmark)
if form.is_valid():
form.save()
return redirect("bookmark_detail", pk=bookmark.pk)
else:
form = BookmarkForm(instance=bookmark)
return render(request, "bookmarks/bookmark_form.html", {"form": form, "title": "Edit Bookmark"})
What does this view do?
- Passing instance=bookmark to the form pre-fills it with the existing bookmark's data for editing.
- On a valid POST, the form saves the updated data and redirects to that bookmark's detail page.
- This view reuses the same bookmark_form.html template as the add view, just with a different title.
- Below bookmark_edit, add the bookmark_delete function:
def bookmark_delete(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
if request.method == "POST":
bookmark.delete()
return redirect("bookmark_list")
return render(request, "bookmarks/bookmark_confirm_delete.html", {"bookmark": bookmark})
What does this view do?
On a GET request, it shows a confirmation page asking "Are you sure?". On a POST request (when the user clicks the delete button), it removes the bookmark from the database and redirects to the list.
This two-step pattern prevents accidental deletions from a single misclick.
- Save views.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
from django.shortcuts import render, get_object_or_404, redirect
from .models import Bookmark
from .forms import BookmarkForm
def bookmark_list(request):
bookmarks = Bookmark.objects.all()
return render(request, "bookmarks/bookmark_list.html", {"bookmarks": bookmarks})
def bookmark_detail(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
return render(request, "bookmarks/bookmark_detail.html", {"bookmark": bookmark})
def bookmark_add(request):
if request.method == "POST":
form = BookmarkForm(request.POST)
if form.is_valid():
form.save()
return redirect("bookmark_list")
else:
form = BookmarkForm()
return render(request, "bookmarks/bookmark_form.html", {"form": form, "title": "Add Bookmark"})
def bookmark_edit(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
if request.method == "POST":
form = BookmarkForm(request.POST, instance=bookmark)
if form.is_valid():
form.save()
return redirect("bookmark_detail", pk=bookmark.pk)
else:
form = BookmarkForm(instance=bookmark)
return render(request, "bookmarks/bookmark_form.html", {"form": form, "title": "Edit Bookmark"})
def bookmark_delete(request, pk):
bookmark = get_object_or_404(Bookmark, pk=pk)
if request.method == "POST":
bookmark.delete()
return redirect("bookmark_list")
return render(request, "bookmarks/bookmark_confirm_delete.html", {"bookmark": bookmark})
- In bookmarks/urls.py, find the urlpatterns list. It currently ends with the add/ path:
path("add/", views.bookmark_add, name="bookmark_add"),
- Add these two new paths below the add/ line, so your urlpatterns list looks like this:
from django.urls import path
from . import views
urlpatterns = [
path("", views.bookmark_list, name="bookmark_list"),
path("<int:pk>/", views.bookmark_detail, name="bookmark_detail"),
path("add/", views.bookmark_add, name="bookmark_add"),
path("<int:pk>/edit/", views.bookmark_edit, name="bookmark_edit"),
path("<int:pk>/delete/", views.bookmark_delete, name="bookmark_delete"),
]
What do these URL patterns do?
The <int:pk> part captures the bookmark's primary key from the URL. Django passes it as the pk argument to bookmark_edit and bookmark_delete so each view knows which bookmark to modify.
- Save urls.py.
Now that the edit and delete URL names exist, you can update the detail template to include buttons for them.
- Open bookmarks/templates/bookmarks/bookmark_detail.html. Find the closing </div> tag just before {% endblock %}:
</div>
{% endblock %}
- Add this paragraph with the Edit and Delete buttons directly above that closing </div>, so the end of the file looks like this:
<p style="margin-top: 15px;">
<a href="{% url 'bookmark_edit' bookmark.pk %}" class="btn btn-primary">Edit</a>
<a href="{% url 'bookmark_delete' bookmark.pk %}" class="btn btn-danger">Delete</a>
</p>
</div>
{% endblock %}
- Save bookmark_detail.html.
The last piece is the delete confirmation template.
- Create a new file called bookmark_confirm_delete.html inside bookmarks/templates/bookmarks/ and paste this code:
{% extends "bookmarks/base.html" %}
{% block title %}Delete {{ bookmark.title }} - Link Vault{% endblock %}
{% block content %}
<div class="card">
<h2>Delete Bookmark</h2>
<p>Are you sure you want to delete "{{ bookmark.title }}"?</p>
<form method="post" style="margin-top: 15px;">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Yes, Delete</button>
<a href="{% url 'bookmark_detail' bookmark.pk %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}
What does this template do?
It shows the bookmark title and asks for confirmation before deleting. The form's POST request triggers the actual deletion in the bookmark_delete view. The Cancel link returns the user to the detail page without making any changes.
- Save bookmark_confirm_delete.html.
- Refresh your bookmark list in the browser. Click a bookmark title to open its detail page. You should now see blue Edit and red Delete buttons.
- Click Edit, change the title, and click Save. Confirm the title updates on the detail page.
- Click Delete, then click Yes, Delete on the confirmation page. Confirm the bookmark disappears from the list.
Seeing a NoReverseMatch error?
This means a template references a URL name that does not exist in your urls.py. Double check that you added both bookmark_edit and bookmark_delete paths to your urlpatterns list and saved the file.
Help me fix a NoReverseMatch error in my Django bookmarks project.
Your bookmark manager now supports the full lifecycle: create, read, update, and delete. Next up, you will tackle a Secret Mission to add search functionality that filters bookmarks by keyword.
Secret mission
Add Search Functionality
Search is one of the most common features employers expect in a web application. Can you add a search bar that filters bookmarks by title or URL?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with Docker, so there are no ongoing cloud costs.
Resources you used:
- MySQL Docker container (service db) with a named volume (linkvault-mysql-data).
- Local project folder (linkvault) containing your Django app, virtual environment, and docker-compose.yml.
Keep everything running
No action needed. Choose this if you are still actively building or plan to add more features to Link Vault.
- Your MySQL Docker container stays up and uses minimal system resources.
- Your Django development server can be restarted anytime with python manage.py runserver.
- Your virtual environment and all installed packages remain in place.
Pause - I'll come back to this later
Stop the Docker container to free up memory. Your data stays safe in the named volume.
- Stop the Django development server by pressing Ctrl+C in its terminal.
- Stop the MySQL container by running this command from your project folder:
docker compose stop
- When you are ready to come back, run docker compose start from your project folder to resume where you left off.
Delete - I don't want to use this again
Remove all project resources completely. This deletes your Docker container, its data volume, and local files.
- Stop the Django development server by pressing Ctrl+C in its terminal.
- Stop and remove the MySQL container and its data volume by running:
docker compose down -v
- Deactivate your virtual environment by running:
deactivate
- Delete the local project folder by running:
macOS
cd ~/Desktop
rm -rf linkvault
Windows
cd ~\Desktop
rmdir /s /q linkvault
Nice Work!
Nice Work!
You just built a full-stack bookmark manager web application from scratch using Django, MySQL, and Docker. That is a production-grade stack that companies use every day.
You learned how to:
- Built a Django project from scratch and connected it to a MySQL database running in Docker using Docker Compose.
- Defined data models with relationships and created function-based views for full CRUD operations (Create, Read, Update, Delete).
- Built a template system with a base template pattern, CSS styling, and configured Django Admin to manage your data.
- Secret Mission: Added search functionality with Django Q objects to filter bookmarks by title or URL.
Ready to quiz yourself?