I built a Blog Engine with Vue, Django and Tailwind

David Dahan
Apr 2021 19 min
tech
web
django
vue
tailwindcss

A few years ago, I had the idea to use Medium, a managed platform for blogging. At the time, I thought it had many advantages like the community, the simplicity of use, etc.

I was right for most of them, but I noticed many drawbacks over the time. Among them, limited tags, no smart way to write in many languages, showing code snippets required another external service like Github, and so on. There were very few ways to customise the displayed text. There were no easy way to export your own data. All of this was kind of annoying but acceptable for a "free" service.

But one day, Medium decided to drastically change its business model, and make a user pay a monthly fee to allow him read more than 3 articles per day (and yes, it's easy to find a workaround, but still…). This, was the straw that broke the camels back. 🤬

Without being a Power Blogger, I decided I wanted to be completely free, build my own features with my own my data, put this blog on my own website, and that's why I decided to try to build a whole blog by myself. 😨

I was asking myself "is it so hard? Why would I need an external platform?" I knew I'd never build the ultimate blogging platform, that's not the point at all. But would I be able to build essential features that suits my own needs?

In this blog article, I'll share all the questions I get through, and will explain the decisions I took to build this blog. This is definitely not a tutorial with the entire code posted (only some snippets are posted to keep it short) but you will easily make yours with the same tools if this is your goal.

If you're in a hurry, you can browse it reading only the titles.

Of course, the article you're reading right now is from this blog. #meta :wink:

Reminder: the current Stack

To understand the following choices, let's state that before making new choices to build this blog, there was an existing website (my personal website), using the following tools:

  • Django (Backend)
  • Vue.js (Single Page App Client)
  • Tailwind (CSS Framework)

The goal was to add a real blog to this website, hence, using the same tools as possible. Anyway I did not want to use a static site for several reasons you'll understand.

🤔 Where does the article data should live?

💡 Scenario 1: Blog Metadata (title, tags, etc.) in backend, and blog content in flat files on the Vue side

This idea could be appealing do have a real-time display while I'm writing the article, using Vue hot module replacement. It would probably help with SEO too. However, this would force me to push code to write a new article or edit an existing one. And it would create coupling between front-end and back-end, leading to more errors.

💡 Scenario 2: All Blog data in back-end

This avoids coupling between front-end and back-end. Now, writing an article is as easy as a new entry in the database. Pretty neat. However, we know this will bring new challenges like SEO, and that I'll need some tools to preview the content I write, to ensure the output is the expected one.

Choice: Scenario 2

The starting models for the back-end

You already understood why I didn't use an external platform like Medium. But what about intermediary solutions with a headless CMS? There are excellent ones like Prepr or Butter CMS.

Well, even if it's often a good idea to not reinvent the wheel, this time it's exactly what I want to do, as explained in the introduction: I want to be free from any external dependancy, any paying service, any "you have to do it this way".

Plus, for those who don't know, Django has been created by reporters at the time, and you'll see the framework provides some welcomed features for making our own blog live.

Let's start with very simple models to build our base blog logic:

class Tag(models.Model):
    name = models.CharField(max_length=512, unique=True)

class Article(models.Model):
    title = models.CharField(max_length=512)
    # for unique but human-friendly URLs
    slug = models.CharField(max_length=512, unique=True),
    # I don't really need a datetime here, date is enough
    date = models.DateField()
    content = models.TextField(blank=True, null=False)
    # Let's not hardcode the author to allow guest posts later
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=PROTECT)
    # To save an article without making it visible to the world
    draft = models.BooleanField(default=True)
    # Tags will help filtering articles later
    tags = models.ManyToManyField(Tag, blank=True)

🤔 How can I host images?

This is trickier than expected.

💡Scenario 1: base64 images

Embedding base64 images has the real advantage of not requiring uploading any data to an external service, or pushing code. The images are just text living in the middle of your article content. Which means I am able to publish a whole article with a simple database entry.

However here are many drawbacks:

  • It will not work with larger medias like videos.
  • The writing user experience is far from perfect: I have long meaningless strings in the middle of my content, and I need to use a converter (online tool, script, etc.) every time I need to drop an image in an article. It's very slow to be displayed too.
  • It's seems it's not usually compatible with RSS readers that requires true image URLs
  • Unlike static assets, base64 images are not cached by HTTP server, which increase server load and response time.

💡Scenario 2: flat images

Why not uploading images on my server, like any static asset?

Remember that unlike SaaS services such as Medium, this blog lives in at least two different environments (the local one on my computer, and the production one, hosted on my hosting service).

As I want my blog to work in both of them, I need dynamic URLs for images like http://127.0.0.1/static/blog/my_image.jpg in local env and https://www.david-dahan.com/static/blog/my_image.jpg in production env.

This would require to write dynamic code like <img :src="blogUrlPrefix + 'blog_1.png'"> in the content article, expecting to find a trick to being able to interpret blogUrlPrefix at the runtime. Spoiler: I found it!

A major drawback is that I can't write an article without pushing code, since the images are currently living in my codebase, decreasing the interest of using a backend for this task.

💡Scenario 3: external hosting services

This is how many blog services work (Wordpress, …). This time I post an article without pushing code, just a database entry and image uploads to this service.

But doing this, I break the strict environment separation, because my local blog requires external service. This could not be a deal breaker these days, but my blog won't work in local without an internet access. I don't like it.

💡Scenario 4: my own assets hosting feature

This external service could be built by myself, plugging vue SPA to Amazon S3. This will remove the need to push some code, but there will still be an issue with the environments.

✅ Choice: Scenario 2

This is not perfect since I still need to push some code currently. However, building a tool to push images on an external service like S3 could be an improvement.

Let's add this field to models.py file to set the path of the image used as miniature:

class Article(models.Model):
    # ...
    # To display an image for each article
    miniature_path = models.CharField(max_length=512)

🤔 Should I write content in Markdown or HTML?

✅ In my opinion, Markdown is clearly superior to HTML for blogging for at least these reasons:

  • It's much easier to write, it does not require an IDE that would make syntax check every time
  • It's still readable without having the output displayed. HTML is less readable with all tags.
  • It has the exact level of markup and customisation I need to write blog articles, if we accept that they're not supposed to have some crazy and fancy custom layout (we'll talk about this soon).

But at some point, I need to transform the Markdown syntax to HTML automatically, since our browsers display HTML, not Markdown. This is what VueShowdownPlugin does.

Showndown.js is a Javascript Markdown to HTML converter, and VueShowdownPlugin is a wrapper that allows to use it easily in a Vue.js project. Using it it as simple as:

<VueShowdown
  <!-- Tailwind class applied to generated HTML for clean output (explained in the next section) -->
  class="prose"
  <!-- Markdown content extracted from database to be transformed -->
  :markdown="content"
  <!-- Allows Vue language to be injected in content itself (used to display images with the right url according to the current environment) -->
  :vueTemplate="true"
  <!-- The dynamic url passed to the content, used for the task explained above -->
  :vueTemplateData="{ blogUrlPrefix }"
/>

🤔 How should I choose the granularity of the style customisations?

How do I want to display the article body? There are two ways of thinking here:

💡 Scenario 1: fine-tuning every article style

Since I have total control over the code, I have the opportunity to fine-tune every article, making crazy layouts. But with great power comes great responsibility. This would become rapidly overwhelming, plus it would force me to use HTML and not Markdown for this purpose, while I just chose Markdown!

💡 Scenario 2: generic and automatic styles

It would be neat if there would exist a way to apply a default styling on raw HTML. And… 🥁… this is the exact purpose of Tailwind Typography which defines itself as:

a plugin that provides a set of prose classes you can use to add beautiful typographic defaults to any vanilla HTML you don't control (like HTML rendered from Markdown, or pulled from a CMS).

Choice: Scenario 2

Perfect, the Tailwind Typography is exactly what I need: to get a beautiful and automatic styling, without ever thinking of it when writing the article itself. I can't emphasise this enough, but NOT dealing with the style is a feature itself: it keeps a global consistency over all your posts, I can change the style for all your articles at once with tuning the style, not the posts.

In addition of the default styling that are okay for 95%, I can can customise the plugin itself for very specific purposes. For example, if I want to change hyperlink colors to match my purple theme, I just need to add in tailwing.config.js file:

extend: {
    typography: theme => ({
      DEFAULT: {
        css: {
          a: {
            color: theme("colors.purple.800"),
            '&:hover': {
              color: theme("colors.purple.500"),
            },
          },
        },
      },
    })
  }

🤔 How do I add automatic Code Syntax Highlighting?

✅ A no-brainer for this is to use hightlight.js. Even if Vue plugins exist, I use it directly from CDN, and add a pretty theme with additional CSS file.

Then hljs.highlightAll() needs to be run (I use it as late as in the updated Vue lifecycle event) to highlight syntax.

🤔 How do I add backend feature such as "Like" button?

Since I have a true backend, I can add any feature without relying on external, and potentially limiting services like Disqus.

Of course, one could argue that famous external services come with the advantage of an existing community, and that lots of people already have an account. After all, who would signup to my website just to like an article? No one!

I strike a balance here, by not using authentication at all! In my case I use IP addresses to know if a user liked the article or not. Of course, it's not perfect at all, since it's easy to change its IP address, and it often changes automatically based on your ISP, but come on, I'm not dealing with a crucial feature here!

Let's update the model with:

class Article(models.Model):
    # ...
    likers = ArrayField(
        models.GenericIPAddressField(), default=list, blank=True, null=False
    )

    @property
    def nb_likes(self) -> int:
        return len(self.likers)

Now I just need to check if the user IP is in the likers array when loading the article. A serializer using Django-Rest-Framework would look like to:

from ipware import get_client_ip  # external package

class ArticleDetailSerializer(serializers.ModelSerializer):
    # ...
    is_liked_by_reader = SerializerMethodField()
    nb_likes = IntegerField() # will use the property defined in related model

    def get_is_liked_by_reader(self, obj) -> bool:
        client_ip, _ = get_client_ip(self.context["request"])
        return client_ip in obj.likers

And of course adding/removing it when the users triggers the button (no need to write another example, you got it).

If you want to test the feature, juste like this article at the end of it 😊

This is just an example for this specific feature. I would probably need more security for other back-end features likes comments.

🤔 Now what is the best workflow to write my own articles?

Given the choice I made to use database to store data, creating a new blog article is as easy as adding a new entry to the database (except for the images).

For that, the built-in Django admin is a dream since I can do this by adding only 2 lines in admin.py file:

from .models import Article, Tag
admin.site.register((Article, Tag))

Then, the GUI is available to add a new article:

One issue here: it seems suboptimal to write markdown in a raw textarea like this. I can't see the output, and I could make silent mistakes with a wrong syntax. Let's review the multiple scenarios:

💡Scenario 1: Django admin + a Markdown Editor

I write the article in the editor, checking syntax, then pasting it in Django admin before saving. Kind of okay, but the output won't be the exact same one than my blog article.

💡Scenario 2: Django admin + a Django plugin to support Markdown in admin GUI

The advantage here would be to stay on Django GUI to create the post, but again, the output won't be the exact same one than my blog article.

💡Scenario 3: Django admin + a custom preview tool page on the vue SPA

I can create a very simple page with a textarea on the left side (to write Markdown code in it), and the output preview on the right side. This time, since I'm using the same tools (Showdown.js, Tailwind Typography, …) with the same prose CSS class, the output will be strictly identical to the final result, including the styles. Once the article is finished, I can check the preview, then copy-paste the Markdown code in Django GUI with confidence.

💡Scenario 4: Build a whole blog posting feature custom preview tool page on the vue SPA

This is the most compelling result, but the one that requires the most work. I'll need to handle authentication on the back-end, make endpoints, handling all Article fields with forms, etc. At this point I would not need Django-admin GUI anymore.

✅ Choice: Scenario 3 for speed (or scenario 4 for beauty)

Building the preview custom tool on Vue is very easy since it's almost the same mechanism that to display the article itself. I just need a textarea with a v-model attribute:

  <div class="flex">
    <div class="w-1/2">
      <textarea
        v-model="content"
        autofocus
        class="text-sm border-r w-full h-screen"
        id="article-text"
      ></textarea>
    </div>
    <div class="w-1/2 overflow-y-scroll">
      <div class="w-full h-screen">
        <VueShowdown class="prose" :markdown="content" />
      </div>
    </div>
  </div>

🤔 How to use tags to filter visible articles to the user?

✅ I have two distinct blogs on my website: Tech Blog section and Life Blog section. Both Vue pages use exactly the same component and the query to the back-end uses filtering with Tag object.

Here is the Serializer that lists Article objects:

class ArticleListView(generics.ListAPIView):
    permission_classes = [AllowAny]
    serializer_class = ArticleSumarySerializer

    def get_queryset(self):
        queryset = Article.objects.prefetch_related("author").exclude(draft=True)
        tag_name = self.request.query_params.get("tag")
        if tag_name is not None:
            tag = generics.get_object_or_404(Tag, name=tag_name)
            queryset = queryset.filter(tags=tag)
        return queryset

Then on the SPA side, to stay DRY and keep a unique component for both pages, I use props with routes:

const routes = [
  // ...
  {
    path: '/blog/tech',
    component: BlogHome,
    props: { tag: 'tech' },
  },
  {
    path: '/blog/life',
    component: BlogHome,
    props: { tag: 'life' },
  },
  {
    path: '/blog/:slug',
    component: BlogPost,
  },
]

And fetching all articles in the BlogHome component using filters looks like:

import { Urls } from '../consts/urls.consts';

export default {
  props: ['tag'],
  data() {
    return {
      urls: Urls,
      articles: [],
    };
  },
  async mounted() {
    const response = await fetch(`${Urls.apiHost}/blog?tag=${this.tag}`);
    this.articles = await response.json();
  },
};

🤔 How do I add RSS feed?

✅Luckily, Django comes with a high-level syndication-feed-generating framework for creating RSS and Atom feeds.

The usage itself is pretty straightforward, even if there are two notable things:

  • I made most of the code generic enough to automatically generate a different feed, based on a given tag:
from django.contrib.syndication.views import Feed

class MyBaseFeed:
    def description(self):
        return f"Latest David's {self.tag_name.title()} Blog Articles"

    def title(self):
        return f"David's {self.tag_name.title()} Blog"

    def link(self):
        return f"/feed/{self.tag_name}/"

    def items(self):
        tag = Tag.objects.get(name=self.tag_name)
        return (
            Article.objects.prefetch_related("tags", "author")
            .published()
            .filter(tags=tag)
            .order_by("-date")[:10]
        )

    # ...


class TechFeed(MyBaseFeed, Feed):
    tag_name = "tech"  # custom attribute

class LifeFeed(MyBaseFeed, Feed):
    tag_name = "life"  # custom attribute
  • I struggled to display images in feed readers. The issue is that to display images, RSS specifications require to provide meta informations about the image (length in bytes, and content type). They can be retrieved with a request, but I want to avoid my server being requested multiple times by RSS readers, because the image does not change over time. Here is how I did:
class MyBaseFeed:
    # ...

    def item_enclosures(self, item: Article):
        return [
            Enclosure(
                item.miniature_full_url,
                item.miniature_meta_content_length,
                item.miniature_meta_content_type,
            )
        ]
class Article(Datable, Slugable, models.Model):
    # ...

    # will be filled automatically when saving object
    miniature_meta_content_length = models.CharField(max_length=512, editable=False)
    miniature_meta_content_type = models.CharField(max_length=512, editable=False)

    def save(self, *args, **kwargs):
        self.update_miniature_metadata()
        super().save()

    @property
    def miniature_full_url(self) -> str:
        """
        For some reasons I ignore, relative urls don't work so I have to write the 
        absolute url. I have no access to a request at this time, hence the choice of 
        using a variable in settings.
        Note that I could use Site framework for this too, but it seems overkill.
        """
        return (
            f"{settings.PROTOCOL}://{settings.DOMAIN}"
            f"{settings.STATIC_URL[:-1]}/blog/{self.miniature_path}"
        )

    def update_miniature_metadata(self) -> None:
        """ HTTP request to get miniature image metadata """
        miniature_data = urllib.request.urlopen(self.miniature_full_url)
        self.miniature_meta_content_length = miniature_data.headers["Content-Length"]
        self.miniature_meta_content_type = miniature_data.headers["Content-Type"]

🤔 How do I write content in different languages?

✅ As a French native speakers, I feel more confident to write in French. However for this article, I thought it would benefit more people if I would make the effort to write it in English. That's why I decided to add a language attribute to the Article object. This is currently used to display the related flag (🇫🇷or 🇬🇧), and could be use as a filter, later.

Of course not an real i18n feature, just a quick win. We can imagine more advanced features with i18n like having an article in multiple languages and serving the right on to the reader.

What's missing now?

NOTE: I'll edit this article every time I add another feature.

We saw all features currently implemented for this blog. For now, I considerer the following features valuable to be added:

🏷️ Using static website abilities

Static websites allow content to be server-side rendered, and pages can be served as raw HTML from a CDN rather than built on the fly by the SPA. This allows the fastest response time possible for the user, and SEO, contrary to our current solution. They're particularly suited for blogs, where content is not supposed to often change.

Idea: take a look to nuxt.js and SSR to this purpose

When I share a link to this article, it would be neat if a miniature is displayed on website like Facebook, Linked In, etc. While it's very easy to do using static websites, using a SPA brings challenges here.

🏷️ Pagination

There are currently 10 articles on this website. The more I add new articles, the more this single query will become slower. That's why I need pagination.

idea: a clean and modern way to process would be to use infinite scroll, scrolling to load more articles, until there is no more.

🏷️ Zoom feature for images

For now, images can not be zoomed. Well, you can still zoom on your smartphone by pinching the screen, or on your desktop by increasing text size, but I mean a feature to do this the clean way.

Idea: use a modal-like component with Tailwind CSS and Headless UI.

🏷️ Markdown parsing utilities

Parsing the markdown file at article saving would allow to add some nice features like:

  • computing the approximate read time (counting words, images, and code lines). For now, I this manually, and it sucks.
  • adding a table of content by parsing titles. This would be especially useful for articles as long as this one.

Idea: use a Python Markdown parser tool, and a background task manager like Celery

🏷️ Content versioning

As I write an article article incrementally, it could be interesting to save all iterations of the content. This would help me to avoid to accidentally delete stuff, of rollback to some previous content if changed my mind.

Idea: use a special field for this with Django

🏷️ Exporting data

Exporting data it is as easy as writing a script. Article body can be exported to .md files and metadata written at the top of these .md files. Images can be downloaded in a folder, but this would imply to override image urls in that case.

Conclusion

We analysed lots of options to build this blog. It's opinionated, incomplete, and far from perfect, but it seems to be a good starting point, what do you think?

I tried to show that external platforms such as Medium are not the only way to go, and that using a backend rather than a static website has some advantages too.

I hope you enjoyed reading this article. 🤘


written by
David Dahan
16 likes