← Back to overview

Blogging has never been so easy

Delivering content for a website is often a difficult task involving a complicated backend. But what if it's possible with minor setup?

Published on May 28, 2025
Blogging has never been so easy

Intro

Hey there! I recently decided to start a new blog, and I wanted to share my experience with you on how I set it up. As a developer, I was looking for something quick and easy without an involved process or even a backend. I also wanted to keep costs minimal, so I was looking for a solution that could compile down to static HTML/JS/CSS files, allowing me to use a static hosting solution. After doing some research, I decided to go with Nuxt.js, a metaframework built around Vue.js.

Why Nuxt.js?

You see, I've been familiar with Vue.js ever since I was introduced to it while working on the website for OINC. Since then, I've been using it extensively in school projects like Boorgir and ColonyLink, where splitting up the code into components made it easier to divide the work. Since the latter was a group project, this made it easy to divide tasks among team members and quickly make progress. ColonyLink was also the first project where I used Nuxt.js as well, which was a total game-changer.

The static site generation aspect of Nuxt.js really stuck out to me. I could see firsthand how it improved page load times and SEO. Moreover, since this functionality generates an index.html file at every possible URL location of the site, we could actually reload the page without it giving us a 404 on most URLs. While other teams struggled with this issue because their sites were simple SPAs, ours worked like a charm and could reload on any page, any time.

For this project, I was drawn to Nuxt.js again after seeing its great support for content-driven projects. I immediately took interest in the @nuxt/content NPM package, essentially a module extending Nuxt's capabilities, as a good target library to build the blogging part with. It makes it possible to create collections of content like blog posts through Markdown files, which allows you to focus on blog content without worrying about syntax or any complicated GUI. Nuxt Content will take care of the HTML conversion for you.

Other than that, I kind of wanted to go all overboard. Besides adding icons using @nuxt/icon, I also added localisation through @nuxt/i18n and light/dark theming through @vueuse/core. I pretty much created all the components for the site myself bar the inherent functionality that these dependencies provide.

How do I actually define content?

For my blog I planned two collection types: one for projects and one for the blog, but I specifically wanted the projects to be localised in both English and Dutch. I therefore created the following directory structure in the root of my directory:

content
├───en
│   ├───blog
│   └───projects
└───nl
    └───projects

and configured Nuxt Content by creating the file content.config.ts at my root directory:

import {
  defineCollection,
  defineContentConfig,
  z,
  type DefinedCollection,
} from "@nuxt/content";

function createCollection(dirPath: string) {
  return defineCollection({
    source: `${dirPath}/*.md`,
    type: "page",
    schema: z.object({
      tags: z.array(z.string()),
      image: z.string(),
      date: z.date(),
    }),
  });
}

const projectLocales = ["en", "nl"];

export default defineContentConfig({
  collections: {
    blog: createCollection("en/blog"),
    ...projectLocales.reduce<Record<string, DefinedCollection>>(
      (obj, locale) => {
        obj[`projects_${locale}`] = createCollection(`${locale}/projects`);
        return obj;
      },
      {}
    ),
  },
});

The above configuration defines three different collections: one for the blog (en/blog) and two for the projects (en/projects and nl/projects).

To then use the content on my pages, I created the following helper script:

import { useAsyncData } from "#app";
import type { Collections } from "@nuxt/content";
import type { CollectionItemType } from "~/types/collections";

export async function getCollectionItems<T extends CollectionItemType>(
  type: T,
  limit?: number
): Promise<Collections[T][]> {
  const { data } = await useAsyncData(`multiple-${type}-${limit}`, () => {
    const collection = queryCollection(type).order("date", "DESC");
    if (limit !== undefined) collection.limit(limit);
    return collection.all();
  });

  return data.value || [];
}

export async function getCollectionItem<T extends CollectionItemType>(
  type: T,
  path: string
) {
  const { data } = await useAsyncData(`single-${type}`, () => {
    const collection = queryCollection(type).where("path", "=", path);
    return collection.first();
  });

  return data.value;
}

By using the above functions, I can easily query data:

// --- Generally speaking: ---
const items = await getCollectionItems("nl/projects", 10); // Get the 10 most recent projects in Dutch
const item = await getCollectionItem("en/blog", "/en/blog/blog-making-of"); // Get the collection item for this blog post

// --- On the slug pages (/blog/[slug] for example - basically the individual blog pages): ---
const route = useRoute()
const slug = Array.isArray(route.params.slug) ? route.params.slug[0] : route.params.slug
const path = `/en/blog/${slug}`
const post = await getCollectionItem('blog', path);

Writing content

Content can be easily written as Markdown files in each collection directory (e.g. I may create the project /my-project under content/en/projects/my-project.md):

---
title: "The Amazeballs ball game"
date: 2025-05-27
description: "A multiplayer game where players compete to be the first person to get their ball to the end of a randomly generated maze."
tags: ["Python", "C", "Game", "Group effort"]
image: /assets/ball-maze.jpg
---

Content that is part of the article goes here.
You can easily use all markdown syntax, such as:
- Bullet points
![Images](/assets/some-image.png)
> Quotes
## Subheadings
`code`
... and more!

If I then want to use this data on my site, I can use the helper functions:

const item = await getCollectionItem("en/projects", "/en/projects/my-project");
item.title // Get the title defined in the config at the top of the file
item.date // Similar here, this returns a Date object which can be trivially localised using Intl.DateTimeFormat
item.description // Get the description
item.tags // Get the tags as an array
item.image // Get the image

Making the articles page

For the article page, it's just a matter of reading the slug of the URL (the last part which says which post you are actually reading) and using it to get the correct collection item with the helper functions. Earlier, I already showed you how I do that, but you of course also need to display it on the page for the reader to see. This is done using the Nuxt component ContentRenderer. If you had a collection item saved in the variable item for example, you would use <ContentRenderer :value="item" /> to show it.

From then on, Nuxt will handle the rendering (everything under the config of the Markdown file is converted to HTML and put on the page). In my application, I defined my blog page to render like this (the function t is for localisation which I will touch on later):

<template>
    <article v-if="item" class="container">
        <header>
            <div class="header-content">
                <NuxtLink to=".">{{ t("back_to_overview") }}</NuxtLink>
                <h1>{{ item.title }}</h1>
                <p>{{ item.description }}</p>
                <div class="meta">
                    <span>{{
                        t("article_publish_date", { date: localisedItemDate })
                    }}</span>
                </div>
            </div>
            <img v-if="item.image" :src="item.image" :alt="item.title" />
        </header>
        <div class="article-content">
            <ContentRenderer :value="item" /> <!-- Built-in renderer from Nuxt -->
        </div>
    </article>
</template>

Of course, it will look quite basic without styling. I added some to make it pop and integrate better with the light/dark theming. This was just a matter of adding CSS variables globally that define my colors and then applying them to the article, just like I did with the rest of the page:

.article-content p code {
    background-color: var(--inline-code-bg);
    color: var(--inline-code-text);
    border-radius: 0.2rem;
    padding-inline: 0.2rem;
}

For code highlighting in multiline code I added the following configuration in my nuxt.config.ts:

export default defineNuxtConfig({
  content: {
    build: {
      markdown: {
        highlight: {
          theme: {
            default: 'github-light',
            dark: 'github-dark'
          },
          langs: ['python', 'yaml']
        }
      }
    }
  }
})

Nuxt will handle the highlighting part. All I had to do now is add the background which changes based on light/dark mode:

.article-content pre:has(code) {
    background-color: var(--surface-bg);
    padding: 1rem;
    border-radius: 1rem;
    overflow-x: auto;
}

Again, the HTML of the article was created by Nuxt Content. I therefore had little control over the structure and it just ended up being the case that for multiline code, it wrapped a <code> tag in a <pre> tag. By looking at the HTML in the browser, I figured out that this quirk was distinct enough so I could add styling for multiline code without affecting other elements on the page.

Localisation

For some ungodly reason, I had to add it. I couldn't do without. While it does complicate things a little bit, like having to define multiple project collections, it's overall quite easy to configure. In nuxt.config.ts, I added the following:

export default defineNuxtConfig({
  ...
  i18n: {
    locales: [
      { code: 'en', iso: 'en-GB', name: 'English', file: 'en.json' },
      { code: 'nl', iso: 'nl-BE', name: 'Nederlands', file: 'nl.json' }
    ],
    defaultLocale: 'en',
    lazy: true,
    langDir: 'locales/',
  }
  ...
})

Then, I made some JSON files in the directory i18n/locales. In this case, en.json and nl.json. These contain key-value pairs that you can use to translate strings across multiple languages on your pages. For example, when using the following en.json:

{
    "header": {
        "home": "Home",
        "projects": "Projects",
        "blog": "Blog"
    },
    "article_publish_date": "Published on {date}",
}

and matching nl.json:

{
    "header": {
        "home": "Home",
        "projects": "Projecten",
        "blog": "Blog"
    },
    "article_publish_date": "Gepubliceerd op {date}",
}

I can get the translation keys like this:

import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
const headerBlog = t('header.blog'); // String I should put in the header to refer to the blog page

const today = new Date(); // Today's date
const localisedDate = new Intl.DateTimeFormat(locale.value, { // Formatted date of today, based on locale
    dateStyle: "long",
}).format(today)

const articlePublishDate = t('article_publish_date', new Date()); // Full published date string, e.g. "Published on May 29, 2025"

To switch locales, I create link elements which people can click on:

<script setup lang="ts">
const switchLocalePath = useSwitchLocalePath()
</script>

<template>
    <NuxtLink :to="switchLocalePath('nl')">Switch to Dutch!</NuxtLink>
    <NuxtLink :to="switchLocalePath('en')">Switch to English!</NuxtLink>
</template>

The above is a bit of a simplification. Of course I put it in a dropdown and added some styling, but you get the idea.

Conclusion

I have to say, the setup process was really straightforward, and I'm impressed with how quickly I was able to get my blog up and running. The fact that I can manage my content as Markdown files is a huge plus, as it makes it easy for me to focus on writing rather than dealing with a complex content management system. Once it was all set up and the different parts worked together, I didn't have to do much more work anymore, other than now adding a markdown file once in a while whenever I want to write about something.

Overall, I'm really happy with how my new blog turned out, and I can't wait to start sharing my thoughts and ideas with the world. If you're looking to set up a blog of your own, I highly recommend giving Nuxt.js and its extensions a try. It's been a game-changer for me, and I'm sure it can be for you too!