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:
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:
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.
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.
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
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)
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:
💡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)
✅ In my opinion, Markdown is clearly superior to HTML for blogging for at least these reasons:
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 do I want to display the article body? There are two ways of thinking here:
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!
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"),
},
},
},
},
})
}
✅ 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.
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.
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:
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.
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.
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.
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>
✅ 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();
},
};
✅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:
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
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"]
✅ 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.
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:
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.
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.
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.
Parsing the markdown file at article saving would allow to add some nice features like:
Idea: use a Python Markdown parser tool, and a background task manager like Celery
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 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.
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. 🤘