How to Create a Modern App with Django and Vue

How to Create a Modern App with Django and Vue

Previously, we talked about how to create a web application using Django, a full-stack Python-based web framework that follows the MTV design pattern. We call it full-stack because we can create both the frontend and the backend with it.

This solution, however, has one small flaw. When the end-user request a webpage, the page will need to be rendered in the backend, and then the rendered HTML page will be sent to the user. As you can imagine, when you have a lot of users, that will put a lot of pressure on your server.

To solve this problem, developers usually split the application into two parts, the backend and the frontend. This way, when the user requests a webpage, instead of rendering the webpage, the backend only gathers the necessary data and transfers them to the frontend. The client's machine, which usually has a lot more excessive computing power, will use the data to render the webpage inside the browser directly, hence relieving the pressure on the server.

In this tutorial, we are going to discuss how to create a modern single-page application using Django as the backend, Vue as the frontend, and GraphQL as the API manipulation language that connects them together.

You can access the source code for this tutorial here šŸ‘ˆ.

A brief review on Django

Let's start with a brief review of the Django framework. Django is a Python-based web framework that follows the MTV architecture.

  • The model (M) is an interface that allows us to interact with the database, such as retrieving, creating, updating or deleting records.
  • The template (T) is the frontend part of the framework, it is the part that the end-users are going to see.
  • The view (V) is the backend logic of the application, it uses the model to interact with the database, such as retrieving the data that is required by the user. Then the view would manipulate the data in some way, and return the result (usually a customized template) to the user.

For this particular tutorial, we are only going to use Django for the backend, which means we are not going to use Django's template or view, and replace them with Vue.js and GraphQL.

Let's start by setting up the Django end.

Creating a fresh Django project

For this tutorial, we are going to separate the backend and the frontend directories. So this is what the project structure should look like:

text
1blog
2ā”œā”€ā”€ backend
3ā””ā”€ā”€ frontend

Go to the backend folder, and create a new Python virtual environment. A Python virtual environment is an isolated environment with a fresh Python install, without the custom packages. When you install packages inside this environment, it will not affect your system's Python environment, which is very important if you are using Linux or macOS, and you don't want to mess with it.

cmd
1cd backend
cmd
1python3 -m venv env

This command will create a new directory called env, and the virtual environment is generated inside. To activate this virtual environment, use the following command:

cmd
1source env/bin/activate

If you are using Windows use this command instead. This depends on personal preference, but we recommend setting up WSL is you are using Windows.

cmd
1env/Scripts/activate

After the virtual environment has been activated, your terminal will look like this. Notice the (env) in front of the username. This indicates you are currently working in the virtual environment.

Python virtual environment

Next, it is time for you to create a new Django project by running the following commands:

cmd
1python -m pip install Django
cmd
1django-admin startproject backend

Create a new application:

cmd
1python manage.py startapp blog

After you are done, the project structure should look like this:

text
1.
2ā”œā”€ā”€ backend
3ā”‚   ā”œā”€ā”€ backend
4ā”‚   ā”œā”€ā”€ blog
5ā”‚   ā”œā”€ā”€ manage.py
6ā”‚   ā””ā”€ā”€ requirements.txt
7ā””ā”€ā”€ frontend

Creating models

Recall that the model is an interface which we can use to interact with the database. And one of the greatest feature of Django is that it can automatically detect the changes you made to the models, and generate the corresponding migration files, which we can use to make changes to the database structure.

  • The Site model

Let's start with the Site model, which stores the basic information of your website.

python
1class Site(models.Model):
2    name = models.CharField(max_length=200)
3    description = models.TextField()
4    logo = models.ImageField(upload_to='site/logo/')
5
6    class Meta:
7        verbose_name = 'site'
8        verbose_name_plural = '1. Site'
9
10    def __str__(self):
11        return self.name

On line 4, ImageField tells Django to upload the image to site/logo/ directory. To make this work, there are two things you need to do.

First, you must install the Pillow package. Django requires it in order to process images.

cmd
1python -m pip install Pillow

Second, you must add a new setting directive in the settings.py. You have to tell Django where you are going to store these media files and what URL you are going to use when accessing these files.

python
1import os
2
3
4# Media Files
5MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')
6MEDIA_URL = '/media/'

This example setting means that the media files will be stored inside the /mediafiles directory, and we'll need to use the URL prefix /media/ to access them. For example, the URL http://localhost:3000/media/example.png will retrieve the image /mediafiles/example.png.

  • The User model

Next, for the User model. Django comes with a built-in User model, which offers basic permission and authorization functionalities. However, for this project, let's try something more complicated. You can add a profile avatar, a bio, and some other information. To do that, you need to create a new User models which extends to the AbstractUser class.

python
1from django.contrib.auth.models import AbstractUser
2
3
4# New user model
5class User(AbstractUser):
6    avatar = models.ImageField(
7        upload_to='users/avatars/%Y/%m/%d/',
8        default='users/avatars/default.jpg'
9    )
10    bio = models.TextField(max_length=500, null=True)
11    location = models.CharField(max_length=30, null=True)
12    website = models.CharField(max_length=100, null=True)
13    joined_date = models.DateField(auto_now_add=True)
14
15    class Meta:
16        verbose_name = 'user'
17        verbose_name_plural = '2. Users'
18
19    def __str__(self):
20        return self.username

Django's AbstractUser class looks like this:

python
1class AbstractUser(AbstractBaseUser, PermissionsMixin):
2    """
3    An abstract base class implementing a fully featured User model with
4    admin-compliant permissions.
5
6    Username and password are required. Other fields are optional.
7    """
8    username_validator = UnicodeUsernameValidator()
9
10    username = models.CharField(
11        _('username'),
12        max_length=150,
13        unique=True,
14        help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
15        validators=[username_validator],
16        error_messages={
17            'unique': _("A user with that username already exists."),
18        },
19    )
20    first_name = models.CharField(_('first name'), max_length=150, blank=True)
21    last_name = models.CharField(_('last name'), max_length=150, blank=True)
22    email = models.EmailField(_('email address'), blank=True)
23    is_staff = models.BooleanField(
24        _('staff status'),
25        default=False,
26        help_text=_('Designates whether the user can log into this admin site.'),
27    )
28    is_active = models.BooleanField(
29        _('active'),
30        default=True,
31        help_text=_(
32            'Designates whether this user should be treated as active. '
33            'Unselect this instead of deleting accounts.'
34        ),
35    )
36    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
37
38    objects = UserManager()
39
40    EMAIL_FIELD = 'email'
41    USERNAME_FIELD = 'username'
42    REQUIRED_FIELDS = ['email']
43
44    class Meta:
45        verbose_name = _('user')
46        verbose_name_plural = _('users')
47        abstract = True
48
49    def clean(self):
50        super().clean()
51        self.email = self.__class__.objects.normalize_email(self.email)
52
53    def get_full_name(self):
54        """
55        Return the first_name plus the last_name, with a space in between.
56        """
57        full_name = '%s %s' % (self.first_name, self.last_name)
58        return full_name.strip()
59
60    def get_short_name(self):
61        """Return the short name for the user."""
62        return self.first_name
63
64    def email_user(self, subject, message, from_email=None, **kwargs):
65        """Send an email to this user."""
66        send_mail(subject, message, from_email, [self.email], **kwargs)

As you can see, it offers some basic fields like first_name, last_name, etc.

Next, you need to make sure that Django is using this new User model as its default User model, or the authentication won't work. Go to settings.py and add the following directive:

python
1# Change Default User Model
2AUTH_USER_MODEL = 'blog.User'
  • The Category model
python
1class Category(models.Model):
2    name = models.CharField(max_length=200)
3    slug = models.SlugField()
4    description = models.TextField()
5
6    class Meta:
7        verbose_name = 'category'
8        verbose_name_plural = '3. Categories'
9
10    def __str__(self):
11        return self.name
  • The Tag model
python
1class Tag(models.Model):
2    name = models.CharField(max_length=200)
3    slug = models.SlugField()
4    description = models.TextField()
5
6    class Meta:
7        verbose_name = 'tag'
8        verbose_name_plural = '4. Tags'
9
10    def __str__(self):
11        return self.name
12
  • The Post model
python
1class Post(models.Model):
2    title = models.CharField(max_length=200)
3    slug = models.SlugField()
4    content = RichTextField()
5    featured_image = models.ImageField(
6        upload_to='posts/featured_images/%Y/%m/%d/')
7    is_published = models.BooleanField(default=False)
8    is_featured = models.BooleanField(default=False)
9    created_at = models.DateField(auto_now_add=True)
10    modified_at = models.DateField(auto_now=True)
11
12    # Each post can receive likes from multiple users, and each user can like multiple posts
13    likes = models.ManyToManyField(User, related_name='post_like')
14
15    # Each post belong to one user and one category.
16    # Each post has many tags, and each tag has many posts.
17    category = models.ForeignKey(
18        Category, on_delete=models.SET_NULL, null=True)
19    tag = models.ManyToManyField(Tag)
20    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
21
22    class Meta:
23        verbose_name = 'post'
24        verbose_name_plural = '5. Posts'
25
26    def __str__(self):
27        return self.title
28
29    def get_number_of_likes(self):
30        return self.likes.count()

Notice how the like system is implemented on line 13. It is not a simple IntegerField, but instead, it works just like tags. And you can use get_number_of_likes() method to get the number of likes for each post.

  • The Comment model

This time, let's go one step further, and create a comment section for this application.

python
1class Comment(models.Model):
2    content = models.TextField(max_length=1000)
3    created_at = models.DateField(auto_now_add=True)
4    is_approved = models.BooleanField(default=False)
5
6    # Each comment can receive likes from multiple users, and each user can like multiple comments
7    likes = models.ManyToManyField(User, related_name='comment_like')
8
9    # Each comment belongs to one user and one post
10    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
11    post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True)
12
13    class Meta:
14        verbose_name = 'comment'
15        verbose_name_plural = '6. Comments'
16
17    def __str__(self):
18        if len(self.content) > 50:
19            comment = self.content[:50] + '...'
20        else:
21            comment = self.content
22        return comment
23
24    def get_number_of_likes(self):
25        return self.likes.count()

Setup Django admin panel

Finally, it is time to set up Django's built-in admin panel. Open the admin.py file:

python
1from django.contrib import admin
2from .models import *
3
4# Register your models here.
5class UserAdmin(admin.ModelAdmin):
6    list_display = ('username', 'first_name', 'last_name', 'email', 'date_joined')
7
8class CategoryAdmin(admin.ModelAdmin):
9    prepopulated_fields = {'slug': ('name',)}
10
11
12class TagAdmin(admin.ModelAdmin):
13    prepopulated_fields = {'slug': ('name',)}
14
15
16class PostAdmin(admin.ModelAdmin):
17    prepopulated_fields = {'slug': ('title',)}
18    list_display = ('title', 'is_published', 'is_featured', 'created_at')
19
20class CommentAdmin(admin.ModelAdmin):
21    list_display = ('__str__', 'is_approved', 'created_at')
22
23
24admin.site.register(Site)
25admin.site.register(User, UserAdmin)
26admin.site.register(Category, CategoryAdmin)
27admin.site.register(Tag, TagAdmin)
28admin.site.register(Post, PostAdmin)
29admin.site.register(Comment, CommentAdmin)

For the CommentAdmin, __str__ refers to the __str__() method in the Comment model. Which will return the first 50 characters concatenated with "...".

Now, start the development server and see if everything works:

cmd
1python manage.py runserver

Django Admin

Before moving to the next step, remember to add some pseudo information for your blog.

A brief review on Vue.js

Now that you are done with the backend, it is time to focus on the frontend. In this second part of this tutorial, let's use Vue.js to create the frontend application. Again, we'll start with a brief review. If you've never used the framework before, please go through the previously linked tutorial first.

Vue.js is a front-end JavaScript framework that provides you with a simple component-based system, which allows you to create interactive user interfaces. Component-based means that the root component (App.vue) can import other components (files with extension .vue), and those components can import more components, which allows you to create very complex systems.

A typical .vue file contains three sections, the <template> section includes HTML codes, the <script> section includes JavaScript Codes, and the <style> section includes the CSS codes.

In the <script> section, you can declare new bindings inside the data() model. These bindings can then be displayed inside the <template> section using the double curly braces syntax ({{ binding }}). The bindings declared inside the data() method will automatically be wrapped inside Vue's reactivity system. Meaning that when the value of the binding changes, the corresponding component will be automatically rerendered, without having to refresh the page.

The <script> section can also contain methods other than data(), such as computed, props, methods and so on. And the <template> also allows us to bind data using directives such as v-bind, v-on and v-model.

Creating a new Vue.js project

In the Vue.js tutorial, we installed and created a Vue app using the Vue command-line tool. This time, we are going to do things differently. We are going to use a frontend build tool called Vite (pronounced as "veet", the French word for fast), which is created by the same author who created Vue.js.

Go into the frontend folder, and run the following command:

cmd
1npm init vue@latest

You will be prompted with multiple options, for this project, you only need to add Vue Router:

text
1āœ” Project name: ā€¦ <your_project_name>
2āœ” Add TypeScript? ā€¦ No / Yes
3āœ” Add JSX Support? ā€¦ No / Yes
4āœ” Add Vue Router for Single Page Application development? ā€¦ No / Yes
5āœ” Add Pinia for state management? ā€¦ No / Yes
6āœ” Add Vitest for Unit testing? ā€¦ No / Yes
7āœ” Add Cypress for both Unit and End-to-End testing? ā€¦ No / Yes
8āœ” Add ESLint for code quality? ā€¦ No / Yes
9āœ” Add Prettier for code formating? ā€¦ No / Yes
10
11Scaffolding project in ./<your_project_name>. . .
12Done.

If you are more comfortable with a strong type language, you can elect to install TypeScript. If you need autocorrect and autoformat for your code, you can install ESlint and Prettier as well. This installation process will generate a package.json file in your project directory, which stores the required packages and their versions. You need to install these packages inside your project.

cmd
1cd <your_project_name>
cmd
1npm install
cmd
1npm run dev

One more thing before we start creating the frontend app. We are using a CSS framework called TailwindCSS in this project. To install it, run the following command:

cmd
1npm install -D tailwindcss postcss autoprefixer
cmd
1npx tailwindcss init -p

This will generate two files, tailwind.config.js and postcss.config.js. This is not a tutorial on CSS or Tailwind, so we assume you already know how to use them. If not, please read Tailwind's official documentation.

Go to tailwind.config.js, and add the path to all of your template files:

javascript
1module.exports = {
2  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
3  theme: {
4    extend: {},
5  },
6  plugins: [],
7};

Create a ./src/index.css file and add the @tailwind directives for each of Tailwindā€™s layers.

css
1@tailwind base;
2@tailwind components;
3@tailwind utilities;

Import the newly-created ./src/index.css file into your ./src/main.js file.

javascript
1import { createApp } from "vue";
2import App from "./App.vue";
3import router from "./router";
4import "./index.css";
5
6const app = createApp(App);
7
8app.use(router);
9
10app.mount("#app");

Now you should be able to use Tailwind inside the .vue files. Let's test it out.

html
1<template>
2  <header>
3    . . .
4    <div class="wrapper">
5      <HelloWorld msg="You did it!" />
6      <h1 class="text-3xl font-bold underline">Hello world!</h1>
7      . . .
8    </div>
9  </header>
10  . . .
11</template>

We added an <h1> heading after <HelloWorld>, and the heading is using the Tailwind classes.

Vue Welcome

Creating routes with Vue router

Notice that this time, your project directory is a little bit different.

Vue router

Inside the src directory, there is a router and a views folder. The router directory contains an index.js file. This is where you can define different routes. Each route will point to a view component that is inside the views directory, and the view can then extend to other components inside the components directory. Vue already provided us with an example of index.js:

javascript
1import { createRouter, createWebHistory } from "vue-router";
2import HomeView from "../views/HomeView.vue";
3
4const router = createRouter({
5  history: createWebHistory(import.meta.env.BASE_URL),
6  routes: [
7    {
8      path: "/",
9      name: "home",
10      component: HomeView,
11    },
12    {
13      path: "/about",
14      name: "about",
15      // route level code-splitting
16      // this generates a separate chunk (About.[hash].js) for this route
17      // which is lazy-loaded when the route is visited.
18      component: () => import("../views/AboutView.vue"),
19    },
20  ],
21});
22
23export default router;

To invoke a defined router, look inside the App.vue file. Instead of the <a> tag, we use <RouterLink> which is imported from the vue-router package.

html
1<script setup>
2  import { RouterLink, RouterView } from "vue-router";
3  . . .
4</script>
5
6<template>
7  <header>
8    . . .
9    <div class="wrapper">
10      . . .
11      <nav>
12        <RouterLink to="/">Home</RouterLink>
13        <RouterLink to="/about">About</RouterLink>
14      </nav>
15    </div>
16  </header>
17
18  <RouterView />
19</template>

When the page is being rendered, the <RouterView /> tag will be replaced with the corresponding view. If you don't want to import these components, simply use <router-link to=""> and <router-view> tags instead.

For our blog application, we need to create at least 6 pages. We need a home page that displays a list of recent pages, a categories/tags page that shows all categories/tags, a category/tag page that displays a list of posts that belongs to the category/tag, and finally, a post page that displays the post content as well as the comments.

So, these are the routers we created. The @ maps to the src directory.

javascript
1import { createRouter, createWebHistory } from "vue-router";
2import HomeView from "@/views/main/Home.vue";
3import PostView from "@/views/main/Post.vue";
4import CategoryView from "@/views/main/Category.vue";
5import TagView from "@/views/main/Tag.vue";
6import AllCategoriesView from "@/views/main/AllCategories.vue";
7import AllTagsView from "@/views/main/AllTags.vue";
8
9const routes = [
10  {
11    path: "/",
12    name: "Home",
13    component: HomeView,
14  },
15  {
16    path: "/category",
17    name: "Category",
18    component: CategoryView,
19  },
20  {
21    path: "/tag",
22    name: "Tag",
23    component: TagView,
24  },
25  {
26    path: "/post",
27    name: "Post",
28    component: PostView,
29  },
30  {
31    path: "/categories",
32    name: "Categories",
33    component: AllCategoriesView,
34  },
35  {
36    path: "/tags",
37    name: "Tags",
38    component: AllTagsView,
39  },
40];
41
42const router = createRouter({
43  history: createWebHistory(process.env.BASE_URL),
44  routes,
45});
46
47export default router;

Please note that in this tutorial, we are only creating the frontend interface, we are not dealing with data transfer just yet, so don't worry about how to find the correct post/category/tag right now.

Creating views, pages, and components

The following images demonstrate the frontend UI we created for this project, which you can download here. You can either use the demo code directly or if you don't like it, you can follow this tutorial on Vue.js and create your own user interface.

Home Page

Home Page

All Categories

All Categories

All Tags

All Tags

Sign In Page

Sign In Page

Sign Up Page

Sign Up Page

Post Page

Post Page

Comment Section

Comment Section

User Profile Page

User Profile Page

User Profile Page Comment Section

User Profile Page Comment Section

Setting up GraphQL and Django

In part two, we are going to talk about how to connect the backend and the frontend. Currently, the industry standard is to use something called REST API, which stands for representational state transfer application programming interface. API refers to the connection between two software applications, and REST refers to a specific architecture that this type of connection follows.

API

A REST API request usually consists of an endpoint, which points to the server, an HTTP method, a header and a body. The header provides meta information such as caching, user authentication and AB testing, and the body contains data that the client wants to send to the server.

However, REST API has one small flaw, it is impossible to design APIs that only fetch the exact data that the client requires, so it is very common for the REST API to overfetch or underfetch. GraphQL was created to solve this problem. It uses schemas to make sure that with each request, it only fetches data that is required, we'll see how this works later.

Before proceeding to the rest of this tutorial, make sure you are familiar with Django and Vue.js.

Let's start by setting up GraphQL in the backend. You need to install a new package called graphene-django. Run the following command:

cmd
1pip install graphene-django

Next, go to settings.py and find the INSTALLED_APPS variable. You must add graphene-django inside so that Django is able to find this module.

python
1INSTALLED_APPS = [
2  . . .
3  "blog",
4  "graphene_django",
5]

Configuring graphene-django

There are still a few things you need to do before you can use GraphQL. First, you need to setup a URL pattern to serve the GraphQL APIs. Go to urls.py and add the following code:

python
1from django.views.decorators.csrf import csrf_exempt
2from graphene_django.views import GraphQLView
3
4urlpatterns = [
5    . . .
6    path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
7]

Next, create the schemas and tell Django where to find them in the settings.py. GraphQL schemas define a pattern that allows Django to translate the database models into GraphQL and vice versa. Let's take the Site model as an example.

python
1class Site(models.Model):
2    name = models.CharField(max_length=200)
3    description = models.TextField()
4    logo = models.ImageField(upload_to='site/logo/')
5
6    class Meta:
7        verbose_name = 'site'
8        verbose_name_plural = '1. Site'
9
10    def __str__(self):
11        return self.name

Create a schema.py file inside the blog directory.

python
1import graphene
2from graphene_django import DjangoObjectType
3from blog import models
4
5# Define type
6class SiteType(DjangoObjectType):
7    class Meta:
8        model = models.Site
9
10# The Query class
11class Query(graphene.ObjectType):
12    site = graphene.Field(types.SiteType)
13
14    def resolve_site(root, info):
15        return (
16            models.Site.objects.first()
17        )

As you can see, this file is divided into three parts. First, you must import the necessary packages and models.

Next, SiteType class is declared, and this SiteType is connected with the Site model.

Lastly, there is a Query class. This class is what allows you to retrieve information using the GraphQL API. To create or update information, you need to use a different class called Mutation, which we'll discuss later.

Inside the Query class, there is a resolve_site function that returns the first record of the Site model. This method automatically binds with the site variable due to its name. This part works exactly the same as the regular Django QuerySet.

Creating schemas

Now you can do the same for all of the models. To make sure the schema file doesn't get too big, you should separate them into schema.py, types.py and queries.py.

schema.py

python
1import graphene
2from blog import queries
3
4
5schema = graphene.Schema(query=queries.Query)

types.py

python
1import graphene
2from graphene_django import DjangoObjectType
3from blog import models
4
5
6class SiteType(DjangoObjectType):
7    class Meta:
8        model = models.Site
9
10
11class UserType(DjangoObjectType):
12    class Meta:
13        model = models.User
14
15
16class CategoryType(DjangoObjectType):
17    class Meta:
18        model = models.Category
19
20
21class TagType(DjangoObjectType):
22    class Meta:
23        model = models.Tag
24
25
26class PostType(DjangoObjectType):
27    class Meta:
28        model = models.Post
29

queries.py

python
1import graphene
2from blog import models
3from blog import types
4
5
6# The Query class
7class Query(graphene.ObjectType):
8    site = graphene.Field(types.SiteType)
9    all_posts = graphene.List(types.PostType)
10    all_categories = graphene.List(types.CategoryType)
11    all_tags = graphene.List(types.TagType)
12    posts_by_category = graphene.List(types.PostType, category=graphene.String())
13    posts_by_tag = graphene.List(types.PostType, tag=graphene.String())
14    post_by_slug = graphene.Field(types.PostType, slug=graphene.String())
15
16    def resolve_site(root, info):
17        return (
18            models.Site.objects.first()
19        )
20
21    def resolve_all_posts(root, info):
22        return (
23            models.Post.objects.all()
24        )
25
26    def resolve_all_categories(root, info):
27        return (
28            models.Category.objects.all()
29        )
30
31    def resolve_all_tags(root, info):
32        return (
33            models.Tag.objects.all()
34        )
35
36    def resolve_posts_by_category(root, info, category):
37        return (
38            models.Post.objects.filter(category__slug__iexact=category)
39        )
40
41    def resolve_posts_by_tag(root, info, tag):
42        return (
43            models.Post.objects.filter(tag__slug__iexact=tag)
44        )
45
46    def resolve_post_by_slug(root, info, slug):
47        return (
48            models.Post.objects.get(slug__iexact=slug)
49        )

Finally, you need to tell Django where to find the schema file. Go to settings.py and add the following code:

python
1# Configure GraphQL
2GRAPHENE = {
3    "SCHEMA": "blog.schema.schema",
4}

To verify that the schemas work, open your browser and go to http://127.0.0.1:8000/graphql. You should see the GraphiQL interface.

GraphiQL

Notice how we are retrieving information in this example, it's the GraphQL language, and it is how we are going to retrieve data in the frontend, which you'll see later.

Setting up CORS

Before you can move on to the frontend, there is still something you need to take care of. By default, data can only be transferred within the same application for security reasons, but in our case we need the data to flow between two applications. To tackle this problem, you must enable the CORS (cross origin resource sharing) functionality.

First, install the django-cors-headers package. Inside the backend app, run the following command:

cmd
1pip install django-cors-headers

Add "corsheaders" to the INSTALLED_APPS variable.

python
1INSTALLED_APPS = [
2  . . .
3  "corsheaders",
4]

Then add "corsheaders.middleware.CorsMiddleware" to the MIDDLEWARE variable:

python
1MIDDLEWARE = [
2  "corsheaders.middleware.CorsMiddleware",
3  . . .
4]

And finally, add the following code to the settings.py.

python
1CORS_ORIGIN_ALLOW_ALL = False
2CORS_ORIGIN_WHITELIST = ("http://localhost:8080",) # Matches the port that Vue.js is using

Setting up Apollo and Vue.js

Now it's time for us to move to the frontend. First, install the Apollo library. It allows you to use GraphQL in the Vue app. To do that, run the following command:

cmd
1npm install --save graphql graphql-tag @apollo/client

Under the src directory, create a new file called apollo-config.js and add the following code:

javascript
1import {
2  ApolloClient,
3  createHttpLink,
4  InMemoryCache,
5} from "@apollo/client/core";
6
7// HTTP connection to the API
8const httpLink = createHttpLink({
9  uri: "http://127.0.0.1:8000/graphql", // Matches the url and port that Django is using
10});
11
12// Cache implementation
13const cache = new InMemoryCache();
14
15// Create the apollo client
16const apolloClient = new ApolloClient({
17  link: httpLink,
18  cache,
19});

Then go to main.js and import the apolloClient:

javascript
1import { apolloClient } from "@/apollo-config";
2createApp(App).use(router).use(apolloClient).mount("#app");

Now we can use the GraphQL language we just saw to retrieve data from the backend. Let's see an example. Go to App.vue, and here we'll retrieve the name of our website.

html
1<template>
2  <div class="container mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
3    <div class="flex flex-col justify-between h-screen">
4      <header class="flex flex-row items-center justify-between py-10">
5        <div class="nav-logo text-2xl font-bold">
6          <router-link to="/" v-if="mySite">{{ mySite.name }}</router-link>
7        </div>
8        . . .
9      </header>
10      . . .
11    </div>
12  </div>
13</template>
14
15<script>
16  import gql from "graphql-tag";
17
18  export default {
19    data() {
20      return {
21        mySite: null,
22      };
23    },
24
25    async created() {
26      const siteInfo = await this.$apollo.query({
27        query: gql`
28          query {
29            site {
30              name
31            }
32          }
33        `,
34      });
35      this.mySite = siteInfo.data.site;
36    },
37  };
38</script>

It is my personal preference to create a separate file for all the queries and then import it into the .vue file.

src/queries.js

javascript
1import gql from "graphql-tag";
2
3export const SITE_INFO = gql`
4  query {
5    site {
6      name
7    }
8  }
9`;

App.vue

html
1. . .
2
3<script>
4  import { SITE_INFO } from "@/queries";
5
6  export default {
7    data() {
8      return {
9        mySite: null,
10      };
11    },
12
13    async created() {
14      const siteInfo = await this.$apollo.query({
15        query: SITE_INFO,
16      });
17      this.mySite = siteInfo.data.site;
18    },
19  };
20</script>

The category page

Now we have a left over problem from the previous section. When we invoke a router, how does the router know which page should be returned? For instance, when we click on a link Category One, a list of posts that belong to category one should be returned, but how does the router know how to do that? Let's see an example.

First, in the router/index.js file where we defined all of our routes, we should set a segment of the URL pattern as a variable. In the following example, the word after /category/ will be assigned to the variable category. This variable will be accessible in the CategoryView component.

javascript
1import { createRouter, createWebHistory } from "vue-router";
2. . .
3
4const routes = [
5  {
6    path: "/",
7    name: "Home",
8    component: HomeView,
9  },
10  {
11    path: "/category/:category",
12    name: "Category",
13    component: CategoryView,
14  },
15  . . .
16];
17
18const router = createRouter({
19  history: createWebHistory(process.env.BASE_URL),
20  routes,
21});
22
23export default router;
24

Next, in the AllCategories view (the one that will show a list of all categories), we will pass some information to this category variable.

html
1<template>
2  <div class="flex flex-col place-content-center place-items-center">
3    <div class="py-8 border-b-2">
4      <h1 class="text-5xl font-extrabold">All Categories</h1>
5    </div>
6    <div class="flex flex-wrap py-8">
7      <router-link
8        v-for="category in this.allCategories"
9        :key="category.name"
10        class=". . ."
11        :to="`/category/${category.slug}`"
12        >{{ category.name }}</router-link
13      >
14    </div>
15  </div>
16</template>

In the Category view, we can access this category variable using this.$route property.

html
1<script>
2  // @ is an alias to /src
3  import PostList from "@/components/PostList.vue";
4  import { POSTS_BY_CATEGORY } from "@/queries";
5
6  export default {
7    components: { PostList },
8    name: "CategoryView",
9
10    data() {
11      return {
12        postsByCategory: null,
13      };
14    },
15
16    async created() {
17      const posts = await this.$apollo.query({
18        query: POSTS_BY_CATEGORY,
19        variables: {
20          category: this.$route.params.category,
21        },
22      });
23      this.postsByCategory = posts.data.postsByCategory;
24    },
25  };
26</script>

And finally, the corresponding posts can be retrieved using the POSTS_BY_CATEGORY query.

javascript
1export const POSTS_BY_CATEGORY = gql`
2  query ($category: String!) {
3    postsByCategory(category: $category) {
4      title
5      slug
6      content
7      isPublished
8      isFeatured
9      createdAt
10    }
11  }
12`;

With this example, you should be able to create the tag and post page.

Creating and updating information with mutations

From the previous section, we learned that we can use queries to retrieve information from the backend and send it to the frontend. However, in a modern web application, it is very common for you to send information from the frontend to the backend. To do that, we need to talk about a new concept called mutation.

Let's go back to the backend and cd into the blog directory, and then create a file called mutations.py. In this example, let's investigate how you can pass data to the backend in order to create a new user.

python
1import graphene
2from blog import models, types
3
4
5# Mutation sends data to the database
6class CreateUser(graphene.Mutation):
7    user = graphene.Field(types.UserType)
8
9    class Arguments:
10        username = graphene.String(required=True)
11        password = graphene.String(required=True)
12        email = graphene.String(required=True)
13
14    def mutate(self, info, username, password, email):
15        user = models.User(
16            username=username,
17            email=email,
18        )
19        user.set_password(password)
20        user.save()
21
22        return CreateUser(user=user)

On line 7, recall that the UserType is tied with the User model.

Line 9 to 12, to create a new user, you need to pass three arguments, username, password and email.

Line 15 to 18, this should be very familiar to you, it is the same way you create a new item using the Django QuerySet.

Line 19, this line of code sets the password. For security reasons, you can not save the user's original password in the database, and set_password() method can make sure it is encrypted.

After that, you must make sure this mutation.py file is included in the GraphQL schema. Go to schema.py:

python
1import graphene
2from blog import queries, mutations
3
4
5schema = graphene.Schema(query=queries.Query, mutation=mutations.Mutation)

To make sure it works, open your browser and go to http://127.0.0.1:8000/graphql to access the GraphiQL interface.

Mutation

graphql
1mutation {
2  createUser(
3    username: "testuser2022"
4    email: "testuser2022@test.com"
5    password: "testuser2022"
6  ) {
7    user {
8      id
9      username
10    }
11  }
12}

You probably already know how to use this in the frontend. Here is an example:

html
1<script>
2  import { USER_SIGNUP } from "@/mutations";
3
4  export default {
5    name: "SignUpView",
6
7    data() {. . .},
8
9    methods: {
10      async userSignUp() {
11        // Register user
12        const user = await this.$apollo.mutate({
13          mutation: USER_SIGNUP,
14          variables: {
15            username: this.signUpDetails.username,
16            email: this.signUpDetails.email,
17            password: this.signUpDetails.password,
18          },
19        });
20       // Do something with the variable user
21       . . .
22      },
23    },
24  };
25</script>

src/mutations.js

javascript
1import gql from "graphql-tag";
2
3export const USER_SIGNUP = gql`
4  mutation ($username: String!, $email: String!, $password: String!) {
5    createUser(username: $username, email: $email, password: $password) {
6      user {
7        id
8        username
9      }
10    }
11  }
12`;

User authentication with Django and Vue.js

Now that you know how to send data to the backend, user authentication shouldn't be too hard. You ask the user to input their username and password and send that information to the backend, and then in the backend, Django finds the user based on username, and it'll try to match the password with the one stored in the database. If the match is successful, the user is logged in.

However, in practice, this plan has some problems. First, sending the user password back and forth isn't exactly safe. You need some way to encrypt the data. The most commonly used method is JWT, which stands for JSON Web Token. It encrypts JSON information into a token. You can see an example here: https://jwt.io/.

This token will be saved inside the browser's local storage, and as long as there is a token present, the user will be considered logged in.

The second problem is caused by Vue's component system. We know that each component is independent. If one component changes, it does not affect the others. However, in this case, we want all components to share the same state. If the user is logged in, we want all components to recognize the user's state as logged in.

You need a centralized place to store this information (that the user is logged in), and all components should be able to read data from it. To do that, you'll need to use Pinia, which is Vue's new official store library created based on Vuex.

JWT in the Backend

First, let's integrate JWT with the Django backend. To do that, you need to install another package called django-graphql-jwt.

cmd
1pip install django-graphql-jwt

Then go to settings.py and add a middleware as well as authentication backend. The configuration will overwrite Django's default setting, allowing it to use JWT instead.

python
1MIDDLEWARE = [
2    "django.contrib.auth.middleware.AuthenticationMiddleware",
3]
4
5# Configure GraphQL
6
7GRAPHENE = {
8    "SCHEMA": "blog.schema.schema",
9    'MIDDLEWARE': [
10        'graphql_jwt.middleware.JSONWebTokenMiddleware',
11    ],
12}
13
14# Auth Backends
15
16AUTHENTICATION_BACKENDS = [
17    'graphql_jwt.backends.JSONWebTokenBackend',
18    'django.contrib.auth.backends.ModelBackend',
19]

To use this package, go to mutations.py and add the following code:

python
1import graphql_jwt
2
3
4class Mutation(graphene.ObjectType):
5    token_auth = graphql_jwt.ObtainJSONWebToken.Field()
6    verify_token = graphql_jwt.Verify.Field()
7    refresh_token = graphql_jwt.Refresh.Field()
8

We can test it in the GraphiQL interface.

Wrong Password

User Auth Wrong Password

User Authenticated

User Authenticated

As you can see, the input arguments are username and password, and if the user is authenticated, an encrypted token will be returned. Later, you can save this token in the browser's local storage.

If you want, you can also customize the behaviour of ObtainJSONWebToken. Go back to mutations.py:

python
1# Customize the ObtainJSONWebToken behavior to include the user info
2
3class ObtainJSONWebToken(graphql_jwt.JSONWebTokenMutation):
4    user = graphene.Field(types.UserType)
5
6    @classmethod
7    def resolve(cls, root, info, **kwargs):
8        return cls(user=info.context.user)
9
10class Mutation(graphene.ObjectType):
11    token_auth = ObtainJSONWebToken.Field()
12

Notice that the ObtainJSONWebToken extends to the default JSONWebTokenMutation, and then in the Mutation class, you can use ObtainJSONWebToken instead.

Now you can make GraphQL return more information about the user.

User auth customization

Pinia in the Frontend

Now it's time for us to solve the second problem in the frontend. Let's start by installing Pinia.

cmd
1npm install pinia

Then, go to main.js and make sure that your app is using Pinia.

javascript
1import { createPinia } from "pinia";
2
3createApp(App).use(createPinia()).use(router).use(apolloProvider).mount("#app");

Go back to the src directory and create a folder called stores. This is where we'll put all of our stores. For now, you only need a user store, so create a user.js file:

javascript
1import { defineStore } from "pinia";
2
3export const useUserStore = defineStore({
4  id: "user",
5  state: () => ({
6    token: localStorage.getItem("token") || null,
7    user: localStorage.getItem("user") || null,
8  }),
9  getters: {
10    getToken: (state) => state.token,
11    getUser: (state) => JSON.parse(state.user),
12  },
13  actions: {
14    setToken(token) {
15      this.token = token;
16
17      // Save token to local storage
18      localStorage.setItem("token", this.token);
19    },
20    removeToken() {
21      this.token = null;
22
23      // Delete token from local storage
24      localStorage.removeItem("token");
25    },
26    setUser(user) {
27      this.user = JSON.stringify(user);
28
29      // Save user to local storage
30      localStorage.setItem("user", this.user);
31    },
32    removeUser() {
33      this.user = null;
34
35      // Delete user from local storage
36      localStorage.removeItem("user");
37    },
38  },
39});

Notice that this store consists of mainly three sections, state, getters and actions. If you already know how to create a Vue application, this should be fairly easy to understand.

state is like the data() method in a Vue component, it is where you declare variables, except these variables will be accessible to all components. In our example, Vue will first try to get the token from the local storage, if the token does not exist, the variable will be assigned the value null.

getters are the equivalent of the computed variables. It performs simple actions, usually just returning the value of a state. Again, it is accessible to all components and pages.

And finally actions are like the methods in a Vue component. They usually perform some action using the states. In this case, you are saving/removing the user's token and information.

One more thing you need to note is that you cannot save objects inside the local storage, only strings. That is why you have to use stringify() and parse() to turn the data into a string and then back into an object.

Next, you need to use this store when log the user in. We created a SignIn.vue file like this:

html
1<script>
2  import { useUserStore } from "@/stores/user";
3  import { USER_SIGNIN } from "@/mutations";
4
5  export default {
6    name: "SignInView",
7
8    setup() {
9      const userStore = useUserStore();
10      return { userStore };
11    },
12
13    data() {
14      return {
15        signInDetails: {
16          username: "",
17          password: "",
18        },
19      };
20    },
21
22    methods: {
23      async userSignIn() {
24        const user = await this.$apollo.mutate({
25          mutation: USER_SIGNIN,
26          variables: {
27            username: this.signInDetails.username,
28            password: this.signInDetails.password,
29          },
30        });
31        this.userStore.setToken(user.data.tokenAuth.token);
32        this.userStore.setUser(user.data.tokenAuth.user);
33      },
34    },
35  };
36</script>

Line 2, imported the user store you just created.

Line 8-11, call the user store in the setup hook, this makes Pinia easier to work with without any additional map functions.

Line 31-32, invoke the setToken() and setUser() actions we just created, this will save the information inside the local storage.

Now, this is how you can log the user in, but what if the user is already signed in? Let's take a look at an example:

html
1<script>
2  import { SITE_INFO } from "@/queries";
3  import { useUserStore } from "@/stores/user";
4
5  export default {
6    setup() {
7      const userStore = useUserStore();
8      return { userStore };
9    },
10
11    data() {
12      return {
13        menuOpen: false,
14        mySite: null,
15        user: {
16          isAuthenticated: false,
17          token: this.userStore.getToken || "",
18          info: this.userStore.getUser || {},
19        },
20        dataLoaded: false,
21      };
22    },
23
24    async created() {
25      const siteInfo = await this.$apollo.query({
26        query: SITE_INFO,
27      });
28      this.mySite = siteInfo.data.site;
29
30      if (this.user.token) {
31        this.user.isAuthenticated = true;
32      }
33    },
34
35    methods: {
36      userSignOut() {
37        this.userStore.removeToken();
38        this.userStore.removeUser();
39      },
40    },
41  };
42</script>

Line 18-19, try to get the token and user info from the store.

Line 30-32, if the token exists, then the user is considered as authenticated.

Line 36-39, this method will log the user out when invoked.

Creating a comment system

Now that you know how to retrieve data using queries and how to send data using mutations, you can try something a little bit more challenging. In this section, let's create a comment and a like reaction system for our blog project.

Let's start with the comment section. There are a few things you need to remember before diving into the code. First, for security reasons, only users that are logged in can leave comments. Second, each user can leave multiple comments, and each comment only belongs to one user. Third, each article can have multiple comments, and each comment only belongs to one article. Last but not least, the comment has to be approved by the admin before showing up on the article page.

Not logged in

Comment section not logged in

Logged in

Comment logged in

Setting up the backend

With that in mind, let's start by creating the model for the comments. This part should be fairly easy to understand if you already know how to work with Django.

python
1# Comment model
2class Comment(models.Model):
3    content = models.TextField(max_length=1000)
4    created_at = models.DateField(auto_now_add=True)
5    is_approved = models.BooleanField(default=False)
6
7    # Each comment belongs to one user and one post
8    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
9    post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True)

Next, go ahead and apply the changes you've made to the models. Go to the terminal and run the following commands.

cmd
1python manage.py makemigrations
cmd
1python manage.py migrate

You also need to set up GraphQL at the backend. You can add a type for the comment model.

python
1class CommentType(DjangoObjectType):
2    class Meta:
3        model = models.Comment

And then the mutation. Note that there are three things Django needs to know to add a comment, the content of the comment, the user that wants to create this comment, and the article that the user is commenting on.

python
1class CreateComment(graphene.Mutation):
2    comment = graphene.Field(types.CommentType)
3
4    class Arguments:
5        content = graphene.String(required=True)
6        user_id = graphene.ID(required=True)
7        post_id = graphene.ID(required=True)
8
9    def mutate(self, info, content, user_id, post_id):
10        comment = models.Comment(
11            content=content,
12            user_id=user_id,
13            post_id=post_id,
14        )
15        comment.save()
16
17        return CreateComment(comment=comment)
python
1class Mutation(graphene.ObjectType):
2    . . .
3    create_comment = CreateComment.Field()

Remember to add the CreateComment class inside the Mutation class.

Setting up the frontend

As for the frontend, let's go to Post.vue, this is where the comments are shown. Please note that weI removed some unrelated code in the following examples, so that the code snippets won't be too long, but if you wish to have the complete code, you can download the source code here.

Post.vue

html
1<script>
2  import { POST_BY_SLUG } from "@/queries";
3  import CommentSectionComponent from "@/components/CommentSection.vue";
4
5  export default {
6    name: "PostView",
7
8    components: { CommentSectionComponent },
9
10    data() {
11      return {
12        postBySlug: null,
13        comments: null,
14        userID: null,
15      };
16    },
17
18    computed: {
19      // Filters out the unapproved comments
20      approvedComments() {
21        return this.comments.filter((comment) => comment.isApproved);
22      },
23    },
24
25    async created() {
26      // Get the post before the instance is mounted
27      const post = await this.$apollo.query({
28        query: POST_BY_SLUG,
29        variables: {
30          slug: this.$route.params.slug,
31        },
32      });
33      this.postBySlug = post.data.postBySlug;
34      this.comments = post.data.postBySlug.commentSet;
35    },
36  };
37</script>

queries.js

javascript
1export const POST_BY_SLUG = gql`
2  query ($slug: String!) {
3    postBySlug(slug: $slug) {
4      . . .
5      commentSet {
6        id
7        content
8        createdAt
9        isApproved
10        user {
11          username
12          avatar
13        }
14        numberOfLikes
15        likes {
16          id
17        }
18      }
19    }
20  }
21`;

First, in the created() hook, you retrieve the requested article as well as the comments using the POST_BY_SLUG query, which is shown above. Next, in the computed property, you need to filter out the comments that are not approved by the admin. And finally, you pass the comment, the post ID and the user ID to the CommentSectionComponent.

CommentSectionComponent.vue

html
1<template>
2  <div class="home">
3    . . .
4    <!-- Comment Section -->
5    <!-- Pass the approved comments, the user id and the post id to the comment section component -->
6    <comment-section-component
7      v-if="this.approvedComments"
8      :comments="this.approvedComments"
9      :postID="this.postBySlug.id"
10      :userID="this.userID"></comment-section-component>
11  </div>
12</template>

Next, let's take a closer look at the comment section component. This component contains two sections, a form that allows the user to leave comments, which is only shown when the user is logged in, and a list of existing comments.

CommentSection.vue

html
1<script>
2  import { SUBMIT_COMMENT } from "@/mutations";
3  import CommentSingle from "@/components/CommentSingle.vue";
4  import { useUserStore } from "@/stores/user";
5
6  export default {
7    components: { CommentSingle },
8    name: "CommentSectionComponent",
9
10    setup() {
11      const userStore = useUserStore();
12      return { userStore };
13    },
14
15    data() {
16      return {
17        commentContent: "",
18        commentSubmitSuccess: false,
19        user: {
20          isAuthenticated: false,
21          token: this.userStore.getToken || "",
22          info: this.userStore.getUser || {},
23        },
24      };
25    },
26    props: {
27      comments: {
28        type: Array,
29        required: true,
30      },
31      postID: {
32        type: String,
33        required: true,
34      },
35      userID: {
36        type: String,
37        required: true,
38      },
39    },
40    async created() {
41      if (this.user.token) {
42        this.user.isAuthenticated = true;
43      }
44    },
45    methods: {
46      submitComment() {
47        if (this.commentContent !== "") {
48          this.$apollo
49            .mutate({
50              mutation: SUBMIT_COMMENT,
51              variables: {
52                content: this.commentContent,
53                userID: this.userID,
54                postID: this.postID,
55              },
56            })
57            .then(() => (this.commentSubmitSuccess = true));
58        }
59      },
60    },
61  };
62</script>

At this point, you should already know how to use Pinia to verify if the user is logged in, and how to use props to pass information between different components, so we are going to skip this part, and let's focus on the submitComment() method.

When this method is invoked, it will test if the comment is empty, and if not, it will use the SUBMIT_COMMENT mutation to create a new comment. The SUBMIT_COMMENT mutation is defined as follows:

mutations.js

javascript
1export const SUBMIT_COMMENT = gql`
2  mutation ($content: String!, $userID: ID!, $postID: ID!) {
3    createComment(content: $content, userId: $userID, postId: $postID) {
4      comment {
5        content
6      }
7    }
8  }
9`;

The following code is the HTML section of CommentSection.vue file. Notice that at the end of this code, we used another component CommentSingle.vue to display one single comment.

CommentSection.vue

html
1<template>
2  <div class=". . .">
3    <p class="font-bold text-2xl">Comments:</p>
4
5    <!-- If the user is not authenticated -->
6    <div v-if="!this.user.isAuthenticated">
7      You need to
8      <router-link to="/account">sign in</router-link>
9      before you can leave your comment.
10    </div>
11
12    <!-- If the user is authenticated -->
13    <div v-else>
14      <div v-if="this.commentSubmitSuccess" class="">
15        Your comment will show up here after is has been approved.
16      </div>
17      <form action="POST" @submit.prevent="submitComment">
18        <textarea type="text" class=". . ." rows="5" v-model="commentContent" />
19
20        <button class=". . .">Submit Comment</button>
21      </form>
22    </div>
23
24    <!-- List all comments -->
25    <comment-single
26      v-for="comment in comments"
27      :key="comment.id"
28      :comment="comment"
29      :userID="this.userID">
30    </comment-single>
31  </div>
32</template>

Now, let's take a closer look at the CommentSingle.vue file.

CommentSingle.vue HTML section

html
1<template>
2  <div class="border-2 p-4">
3    <div
4      class="flex flex-row justify-start content-center items-center space-x-2 mb-2">
5      <img
6        :src="`http://127.0.0.1:8000/media/${this.comment.user.avatar}`"
7        alt=""
8        class="w-10" />
9      <p class="text-lg font-sans font-bold">
10        {{ this.comment.user.username }}
11      </p>
12    </div>
13
14    <p>{{ this.comment.content }}</p>
15  </div>
16</template>

CommentSingle.vue JavaScript section

html
1<script>
2  export default {
3    name: "CommentSingleComponent",
4    data() {
5      return {
6        . . .
7      };
8    },
9    props: {
10      comment: {
11        type: Object,
12        required: true,
13      },
14      userID: {
15        type: String,
16        required: true,
17      },
18    },
19  };
20</script>

Creating a like reaction system

Like system

As for the like system, there are also a few things you need to keep in mind. First, the user has to be logged in to add a like. Unverified users can only see the number of likes. Second, each user can only send one like to one article, and clicking the like button again would remove the like reaction. Lastly, each article can receive likes from multiple users.

Setting up the backend

Again, let's start with the models.

Since each article can have many likes from many users, and each user can give many likes to many articles, this should be a many-to-many relationship between Post and User.

Also notice that this time a get_number_of_likes() function is created to return the total number of likes. Remember to apply these changes to the database using the commands we've talked about before.

python
1# Post model
2
3class Post(models.Model):
4    . . .
5
6    # Each post can receive likes from multiple users, and each user can like multiple posts
7    likes = models.ManyToManyField(User, related_name='post_like')
8
9    . . .
10
11    def get_number_of_likes(self):
12        return self.likes.count()
13

Next, we add the types and mutations.

python
1class PostType(DjangoObjectType):
2    class Meta:
3        model = models.Post
4
5    number_of_likes = graphene.String()
6
7    def resolve_number_of_likes(self, info):
8        return self.get_number_of_likes()
9

Notice that in line 8, self.get_number_of_likes() invokes the get_number_of_likes() function you defined in the model.

python
1class UpdatePostLike(graphene.Mutation):
2    post = graphene.Field(types.PostType)
3
4    class Arguments:
5        post_id = graphene.ID(required=True)
6        user_id = graphene.ID(required=True)
7
8    def mutate(self, info, post_id, user_id):
9        post = models.Post.objects.get(pk=post_id)
10
11        if post.likes.filter(pk=user_id).exists():
12            post.likes.remove(user_id)
13        else:
14            post.likes.add(user_id)
15
16        post.save()
17
18        return UpdatePostLike(post=post)
19

To add a like to a post, you need to know the id of the article, and the id of the user that likes this article.

From line 11 to 14, if the post already has a like from the current user, the like will be removed, and if not, a like will be added.

Setting up the frontend

Next, we need to add a like button to our post page. Go back to Post.vue.

Post.vue HTML section

html
1<template>
2  <div class="home">
3    . . .
4
5    <!-- Like, Comment and Share -->
6    <div class=". . .">
7      <div v-if="this.liked === true" @click="this.updateLike()">
8        <i class="fa-solid fa-thumbs-up">
9          <span class="font-sans font-semibold ml-1"
10            >{{ this.numberOfLikes }}</span
11          >
12        </i>
13      </div>
14      <div v-else @click="this.updateLike()">
15        <i class="fa-regular fa-thumbs-up">
16          <span class="font-sans font-semibold ml-1"
17            >{{ this.numberOfLikes }}</span
18          >
19        </i>
20      </div>
21      . . .
22    </div>
23
24    . . .
25  </div>
26</template>

Post.vue JavaScript section

html
1<script>
2  import { POST_BY_SLUG } from "@/queries";
3  import { UPDATE_POST_LIKE } from "@/mutations";
4  . . .
5
6  export default {
7    . . .
8    async created() {
9      . . .
10      // Find if the current user has liked the post
11      let likedUsers = this.postBySlug.likes;
12
13      for (let likedUser in likedUsers) {
14        if (likedUsers[likedUser].id === this.userID) {
15          this.liked = true;
16        }
17      }
18
19      // Get the number of likes
20      this.numberOfLikes = parseInt(this.postBySlug.numberOfLikes);
21    },
22
23    methods: {
24      updateLike() {
25        if (this.liked === true) {
26          this.numberOfLikes = this.numberOfLikes - 1;
27        } else {
28          this.numberOfLikes = this.numberOfLikes + 1;
29        }
30        this.liked = !this.liked;
31
32        this.$apollo.mutate({
33          mutation: UPDATE_POST_LIKE,
34          variables: {
35            postID: this.postBySlug.id,
36            userID: this.userID,
37          },
38        });
39      },
40    },
41  };
42</script>

We omitted some code to make this example shorter, but there are still a few things we need to talk about in this example. First, the POST_BY_SLUG query that you use to retrieve the article, you need to make sure that it returns the number of likes and the users that already liked the article.

queries.js

javascript
1export const POST_BY_SLUG = gql`
2  query ($slug: String!) {
3    postBySlug(slug: $slug) {
4      . . .
5      numberOfLikes
6      likes {
7        id
8      }
9      . . .
10    }
11  }
12`;

Next, in the created() hook, after you've retrieved the post, you must determine if the current user is in the list of users that already liked the post.

Then, in the updateLike() method, when this method is invoked, it will change the number of likes based on whether or not the user has liked the post.

Finally, the method updates the post's likes in the backend using the UPDATE_POST_LIKE mutation.

mutations.js

javascript
1export const UPDATE_POST_LIKE = gql`
2  mutation ($postID: ID!, $userID: ID!) {
3    updatePostLike(postId: $postID, userId: $userID) {
4      post {
5        id
6        title
7        likes {
8          id
9        }
10      }
11    }
12  }
13`;

A final challenge

After learning how to create a comment and a like system, let's consider a more challenging task. What if we want to create a nested commenting system, where users can comment on another comment? How can we change our code to make this possible? And how can we create a like system for the comment as well?

The complete implementation of these functionalities are included in the source code of this tutorial.

Interested in Learning Web Development?

Starting from HTML, CSS to JavaScript, from language syntaxes to frameworks, from the frontend to the backend, we'll guide you through every step of your coding journey.

Get started from here šŸŽ‰