Slug routing in Django
2 April, 2020
URL routing with slugs is a lot prettier (and possibly a bit safer) than simply routing with IDs. The idea behind slugs is that they're used to generate a human-readable, more meaningful URL. For example, the URL for this post is probably going to end up as heds.nz/blog/slug-routing-in-django/. That's quite a bit better than heds.nz/blog/15/, which is what it would have been if I'd left the URLs to simply reflect the primary key of the database entry! Additionally, slugs probably provide better search engine optimization for your website. So, this tutorial will to show you how to set up a Django website from scratch, complete with working slug URLs.
The setup
To do this, we're going to create a new Django project that we'll use to make a blog website with. In our blog app we'll have a BlogPost model, for which we'll provide fields for the URL slug.
Firstly, let's create a top-level directory for our project. In the interests of keeping everything for this tutorial in one place, I'm going to create a virtual environment within this directory, as well as the directory for the Django app itself. I'm using Git Bash for this, but use whatever works for you.
$ mkdir slug_routing_in_django
$ cd slug_routing_in_django
Okay, let's create our virtual environment, and name it 'my_env'. This will create a new folder in the current directory. After it's created, activate it. (Note that this will depend on whether you're using Windows or Unix/MacOS; see here for more on virtual environments.)
$ python -m venv my_env
$ source my_env/Scripts/activate
Alright, we should have an activated virtual environment set up now; we can confirm this by checking that the terminal has the name of our virtual environment preceding the '$' sign in parentheses. Let's install Django.
(my_env) $ pip install django
Now let's create our Django project. This should create a new folder, 'website'. Once it's created, navigate to the folder and get the database up and running with migrate
, then start the development server to check that it's all working.
(my_env) $ django-admin startproject website
(my_env) $ cd website
(my_env) $ python manage.py migrate
(my_env) $ python manage.py runserver
Alright, hopefully that's all working fine and you can see the default Django landing page at localhost:8000/. Stop the app with Ctrl + C
. Now, we'll create our blog app.
(my_env) $ python manage.py startapp blog
Navigate to ~/slug_routing_in_django/website/website/settings.py and add the blog app to the INSTALLED_APPS
setting.
INSTALLED_APPS: [
...
'blog',
]
Adding the model
Okay, we're now ready to add our BlogPost model! This is going to be really simple, and consist of only a few fields. We have title, blog post content and slug fields. Note that there is a specific SlugField
in Django, and we've set unique=True
so that each post must have a unique slug, and null=False
, so that each post requires a slug. We'll also define __str__
and get_absolute_url
.
from django.db import models
class BlogPost(models.Model):
title = models.CharField(max_length=255)
content = models.TextField()
slug = models.SlugField(unique=True, null=False)
def __str__(self):
return self.title
def get_absolute_url(self):
from django.urls import reverse
return reverse('detail', kwargs = {'slug': self.slug})
Okay, since we've updated our model, we need to apply the migrations.
(my_env) $ python manage.py makemigrations
(my_env) $ python manage.py migrate
Creating a test post
Now that we've created our BlogPost model, we need to add a blog post! In the real world, we'd set up the Django admin app and do it from there, but in the interests of keeping this simple and light, let's just use the database API to create a couple of posts from the terminal. The official Django tutorial covers this quite nicely.
(my_env) $ python manage.py shell
Let's import the BlogPost model, make a couple of quick test posts, then save them. Note that we've provided the title, content and slug fields. Exit the shell afterwards.
>>> from blog.models import BlogPost
>>> p1 = BlogPost(title='Why dogs are better than cats', content='Do you
even fetch, bro?', slug='why-dogs-are-better-than-cats')
>>> p1.save()
>>> p2 = BlogPost(title='How to finish a Risk game in under 12 hours',
content='Just kidding', slug='how-to-finish-a-risk-game-in-under-12-hours')
>>> p2.save()
>>> exit()
URL routing
Before we can check out our cool new post, we need to do some fun URL routing. Firstly, let's go into website/website/urls.py and replace the contents with the following code. In the urlpatterns
list, we tell Django that when it comes across a URL beginning with "blog", it should look in website/blog/urls.py to find the appropriate URL routing. To do this, we need to import django.urls.include
. urls.py should now look like this:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path(route='admin/', view=admin.site.urls),
path(route='blog/', view=include('blog.urls'))
]
website/blog/urls.py doesn't actually exist at the moment, so let's create it.
(my_env) $ touch blog/urls.py
All that we're going to add to the file at this stage is the following code, which tells Django that when it comes across a route of '', it should call the blog
function in our view. In practise, this means that when we navigate to localhost:8000/blog/, we'll call our index
view. (Remember, the project-level urls.py told us to look in blog/urls.py when it comes across a URL starting with 'blog'; below, we've given a path of '', so that's equivalent to localhost:8000/blog/.)
from django.urls import path
urlpatterns = [
path('', views.index, name='index'),
]
Adding the views and templates
Sweet. Now we need to make our views. We've set up the routing to call the appropriate view based on the provided URL. In Django, *views* are functions (or classes), by convention stored in views.py files, that handle web requests by taking a request object and returning a response object. If we're doing things like querying our database to return a post, this is where we specify logic for that. The returned object will also specify which template to render. You can read more about views here in the official docs.
Open mywebsite/blog/views.py. In our URL routing above, we set our route of '' to route to views.blog
, so we need to create a index
function in this file. We're going to tell it to just grab all of the BlogPost objects in the database, and return them in the database context.
from django.shortcuts import render
from blog.models import BlogPost
def index(request):
posts = BlogPost.objects.all()
context = {
"posts": posts,
}
return render(request, 'index.html', context)
For now, the final piece of this particular sluggy puzzle is the template. Let's create a templates directory for the blog app, and create index.html that we called in the index
function above.
(my_env) $ mkdir blog/templates
(my_env) $ touch blog/templates/index.html
Alright! Let's open this up, and add the following code. Remember that in website/blog/views.py, we defined the index
request to get all the BlogPost objects in the database, and return them as posts
? Well, in our template, we can access these directly using the posts
variable. We're just going to grab all the BlogPost.title
objects in our database context. Our new index.html should look like this:
<h1>Blog index</h1>
{% for post in posts %}
{{ post.title }}
{% endfor %}
Let's check that all these moving parts are talking to each other okay. Run the app with (my_env) $ python manage.py runserver
, and navigate to localhost:8000/blog/. We should see our 'Blog index' h1
header, and the title of our two blog posts. Awesome!
But what about the slugs!?
We're getting there. To implement the slugs, we'll need to create a view that shows the detail (that is, the content) of a blog post. Stop the dev server with Ctrl + C
. Open up website/blog/views.py, and add the following view:
def detail(request, slug):
post = BlogPost.objects.get(slug=slug)
context = {
"post":post,
}
return render(request, 'detail.html', context)
Have a close look at the view. Firstly, as well as the request, we've also asked for a slug parameter. It's telling us to check the database for the post with a slug that matches the provided slug argument. So, how do we pass our slug argument along with the request? We'll have to return to our URLs. Open website/blog/urls.py, and update it to include the following pattern, which will allow us to enter a slug into the URL path:
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('<slug:slug>/', views.detail, name='detail'),
]
Finally, we need to make our detail.html template, and get it to show us the content of the blog post. Create the file and open it up.
(my_env) $ touch blog/templates/detail.html
We'll give it an h2
header for the title, and wrap the content in p
tags. Can't get much simpler than this. Remember that the post
variable is available to this template because it's been passed along by the view.
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
And that's it! Save the files are run (my_env) $ python manage.py runserver
. Navigate to localhost:8000/blog/, and you'll see the blog index containing all (two...) posts in the database. Append a post's slug to the URL (e.g., http://localhost:8000/blog/why-dogs-are-better-than-cats/), and we'll see our detail
view, complete with title and content!
Reverse URL resolution
Alright, there's a lot more to do to have a functioning website, but it's a good idea to get the URL routing done the right way from the beginning. There are some more things to think about, though.
The most obvious next step is generating links to detail.html (nobody is going to type in your blog post slugs by hand...). This is why we defined get_absolute_url
in the BlogPost model, which is Django best practice. It uses reverse URL resolution. The idea is that we provide the view, as well as any arguments that are passed to it, and it generates the URL for us. This is really important in keeping with DRY (don't repeat yourself) principles (we don't want to be hardcoding our URL slugs all over the place!). Recall how we defined get_absolute_url
:
def get_absolute_url(self):
from django.urls import reverse
return reverse('detail', kwargs = {'slug': self.slug})
When we call this function, we provide the relevant slug, and it passes it on to the detail
. To see how we can implement this in our template, open up blog/templates/index.html and paste in the following code:
<h1>Blog index</h1>
{% for post in posts %}
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
{% endfor %}
We've added an a
tag with an href
pointing to post.get_absolute_url
. To put this all together, our index
view grabs all the BlogPost objects in the database, and returns them as posts
in the context. Our template, index.html, takes those posts, and for each one, calls BlogPost.get_absolute_url
, passing in the relevant slug that is stored in the context. That function generates a URL that points us at the detail
view, so when we click on the a
tag, we get routed to that view.
Try it out now. Our new blog index, localhost:8000/blog/, should now display the titles of our two blog posts as hyperlinks to the relevant detail pages, where the post content is rendered. Simple :)
Final thoughts
If you're publishing a lot of content, you might eventually get some duplicate slugs. I've ensured that this doesn't pop up as a silent issue by defining unique=True
in the BlogPost.slug field; nevertheless, you may want to think about creating a composite slug + ID pattern for your URLs if you think that might be an issue.
You could also consider using Django's prepopulated fields; when you create a post in your admin page, you can use this functionality to automatically create a slug based on the title of your post.