Navigate back to the homepage
Browse Repo

Building Your Own Blog with Nuxt Content and Tailwind

Mads Brodt
August 30th, 2021 · 11 min read

Hey there, and welcome to this tutorial. This will be an in-depth guide on creating your own personal blog using Nuxt, a meta-framework built on top of VueJS. Nuxt is designed for developer productivity, and includes lots of great features out of the box to help us create awesome websites. We’re also gonna be using TailwindCSS to style our blog and make it look 🔥

What we’ll be building

We’re gonna be building a static blog website with NuxtJS and TailwindCSS. We’re gonna use the Nuxt Content module to allow us to easily write and display blog posts using Markdown. By using Nuxt Content, we can supercharge our posts by including HTML or even Vue components inside the markdown files, providing us a seamless and flexible authoring experience to create awesome content - without needing a Content Management System (CMS).

We’re gonna add tags and images to our posts and present them in a nice grid on our homepage. Additionally, we’ll add a Table of Content to each individual blog post so users can easily navigate our content. And using the Tailwind Typography plugin, we’ll style our blog posts nicely including syntax highlighting for code snippets. Finally, we’ll make sure our blog posts has the proper meta information so Google and social platforms can index our content.

This is what the homepage of our blog will look like: null

And then our blog posts will show their image, tags, ToC and content like so: null

Let’s get started!

Setup with create-nuxt-app

To setup the project, make sure you have NodeJS installed on your machine, as well as access to a terminal (any terminal will do). You’ll also need either Yarn or npm (comes with NodeJS)

Start by running:

1npx create-nuxt-app nuxt-content-blog

This will ask you a bunch of questions to help you setup the project. Feel free to pick any options you like, but for simplicity, this is what I went with:


The key thing is that you add the “Content - Git-based headless CMS” module, as that’s the module that will be driving our blog. You can also pick another styling framework if you prefer, but I’m gonna use Tailwind CSS as it provides awesome results and a really nice workflow. It’ll also be helpful when we get to styling our blog post content.

When it’s done, you can cd into the project. Open it with your favourite editor (I use VS Code) and run yarn dev (or npm run dev). Open your browser and head to localhost:3000 to get greeted by the Nuxt welcome screen:


Now go back to your editor, where Nuxt has set up some files and folders for us to use:

  1. The pages folder will include any routes/pages in your application (more on that later).
  2. The components is what we will use to store our smaller components that pages can use.
  3. The static folder is where we’ll be putting all the images needed for our blog posts.
  4. The content folder is because we’re specifically using the Nuxt Content module, and we’ll use it to contain all our individual blog posts. Nuxt has even made an example file we can get started with.

Last piece of the setup, if this is your first time working with Vue, you’ll also want to install the Vetur extension in VsCode. Just search for it in the Extensions Panel and install it, as it’ll give you a bunch of awesome features and quality of life improvements when writing Vue code.

Creating and displaying blog posts with Nuxt Content

Now we’re ready to start building! Let’s start by adding a few more posts. We’ll use something called frontmatter (which comes automatically with Nuxt Content) to describe some basic data about each of our posts. If you open up, the frontmatter is the information between the dashes at the top:

2title: Getting started
3description: 'Empower your NuxtJS application with @nuxt/content module: write in a content/ directory and fetch your Markdown, JSON, YAML and CSV files through a MongoDB like API, acting as a Git-based Headless CMS.'

We’re gonna be adding some fields to our frontmatter, so rename to and make it look like the following:

2title: My first blog post
3image: first-post.jpg
5 - Front-end
6 - Programming
7 - Career

Now, copy paste this post in the same directory to create a couple more. Update their filename, title, image path and tags to something else to make them unique:


The image key refers to an image path, and can be any URL on the web. In this case, I’ve added 3 random images from Unsplash in my static folder. These images will then be available at localhost:3000/first-post.jpg for example. Just make sure the name of the file matches the path provided exactly, and that the images are inside the static folder.

Display post info on homepage

Next up, we’re gonna show links to our posts on the homepage, which we’ll do in the index.vue file. We’re gonna use an built-in Nuxt function called asyncData to fetch the post data and make them available to our homepage.

We’ll add this function to our <script> tag:

1export default {
2 async asyncData({ $content }) {
3 const posts = await $content()
4 .only(['title', 'image', 'tags', 'slug'])
5 .sortBy('createdAt', 'desc')
6 .fetch()
8 console.log("posts", posts)
10 return {
11 posts,
12 }
13 },

Here we’re using the $content feature from Nuxt to load in some post data (the title, image, tags and slug of each post). We then sort them in descending order (newest first), and call fetch() to execute the function. Finally we’re returning posts, which exposes the post data to our template as an array of posts. Check the console to see all the data we just grabbed:


Let’s update our template to show the title of each post (still in index.vue):

2 <div>
3 <h1 class="text-7xl font-black text-center">New on the blog</h1>
4 <div v-for="post in posts" :key="post.title">
5 <h2 class="mt-2 text-3xl font-semibold">{{ post.title }}</h2>
6 </div>
7 </div>

If all went well, you should see your 3 post titles on the screen - and remember that this title is coming directly from the frontmatter of each individual post, so feel free to update the titles to something else. We’re also fetching the slug, image and tags as we’ll need those in a bit.

Creating post routes

Of course we want each post to have it’s own page to display the body content, tags, image etc. We’ll do that by creating a dynamic route in Nuxt.

Inside your pages folder, create a new folder called blog. Then, create a single file called _slug.vue inside the blog folder. This will be our dynamic route that will display an individual post. Add the following code:

2 <h1>{{ post.title }}</h1>
6export default {
7 async asyncData({ $content, params }) {
8 const post = await $content(params.slug).fetch()
9 return { post }
10 },

Now what’s going on here? Because we named the file _slug.vue and put it inside a blog folder, Nuxt will render this page component whenever it hits a URL at /blog/something. And when it does, the params object in asyncData will have access to the slug (the part of the URL after blog/, in this case “something”). We then use that slug to fetch a single post using the $content feature again. By returning post, we expose this data to the template, just like we did on the homepage when fetching all the posts.

To verify that it works, visit localhost:3000/blog/first-post and you should see the post title “My first blog post” on the screen. Because first-post is the slug, that slug gets matched up with our markdown file

Creating proper post previews

Now that we know our blog post routes are working, let’s go back to index.vue and make our post previews more appealing and actually link them to the individual posts. Let’s update the post loop to use a new PostPreview component that we’ll create in a second, and pass each post object to this component. We’ll also wrap the loop in a list with some grid classes to make them align nicely. With these changes, our template in index.vue should look like this:

2 <div>
3 <h1 class="text-7xl font-black text-center">New on the blog</h1>
4 <ul class="grid grid-cols-3 gap-8 mt-8">
5 <PostPreview v-for="post in posts" :key="post.slug" :post="post"></PostPreview>
6 </ul>
7 </div>

Let’s then create the PostPreview.vue component inside the components folder. This component will accept a post object as a prop, and then display the post image and title, and act as a link to the post itself. We’ll even use another cool new Nuxt feature to get optimized images called Nuxt Image.

Paste the following in your PostPreview.vue file:

2 <li class="transition-all duration-500 ease-in-out">
3 <a :href="`/blog/${post.slug}`">
4 <nuxt-img
5 class="rounded-lg"
6 :src="post.image"
7 preset="preview"
8 width="400"
9 height="300"
10 />
11 <ul v-if="post.tags" class="flex space-x-3 mt-2">
12 <li class="text-gray-400 font-bold" v-for="tag in post.tags" :key="tag">
13 {{ tag }}
14 </li>
15 </ul>
16 <h2 class="mt-2 text-3xl font-semibold">{{ post.title }}</h2>
17 </a>
18 </li>
22export default {
23 props: {
24 post: Object,
25 }

Now when you look at the homepage, you’ll see the title of each post along with its tags. But now they work as links so you can click each one and go to that post’s page.

To get the images working, we’ll need to install the @nuxt/image module. Do that by running:

yarn add @nuxt/image

Then, head into your nuxt.config.js and add it to your buildModules array (alongside the Tailwind module that was automatically installed in setup):

1buildModules: [
2 '@nuxtjs/tailwindcss',
3 '@nuxt/image'

Finally, add the following to nuxt.config.js as well. This will tell @nuxt/image to optimize for different screen sizes and add a preset we’ll use in our PostPreview:

1image: {
2screens: {
3 xs: 320,
4 sm: 640,
5 md: 768,
6 lg: 1024,
7 xl: 1280,
8 xxl: 1536,
9 '2xl': 1536,
11presets: {
12 preview: {
13 modifiers: {
14 fit: 'cover',
15 format: 'jpg',
16 width: 400,
17 height: 300,
18 },
19 },

Reload the homepage, and you should now see our images show up: null

Adding a layout

Great! But the page itself could use some max width to make it look better. There’s many ways we could do this, but let’s use another Nuxt feature: Layouts.

Create a new folder in the root of your project called layouts. Inside that, create a file called default.vue and paste the following in there:

2 <div class="min-h-screen w-screen pt-24">
3 <div class="max-w-7xl mx-auto">
4 <Nuxt />
5 </div>
6 </div>
10export default {}

Now all our pages will follow this layout and won’t span the entire page. The <Nuxt /> component is a special component that will render the content of the page we’re currently on (either the homepage or a blog page in our case). With this new layout, our homepage looks a bit tighter:


Displaying post content

Now that we got a nice looking homepage, let’s move on to the actual blog posts in our _slug.vue file. There’s gonna be a lot going on here, so start by setting your template up like this, and we’ll break it down bit by bit:

2 <article class="grid grid-cols-12 pb-24">
3 <nuxt-img
4 class="rounded-lg col-start-3 col-span-8 w-full"
5 :src="post.image"
6 width="768"
7 height="509"
8 />
10 <div class="w-full col-start-1 col-span-12 grid grid-cols-12">
11 <nav class="mt-8 col-span-2 shadow-2xl rounded-lg p-4 self-start">
12 <ul class="space-y-2">
13 <li v-for="link of post.toc" :key="">
14 <NuxtLink
15 class="uppercase tracking-wider text-gray-500 hover:underline"
16 :to="`#${}`"
17 >{{ link.text }}</NuxtLink>
18 >
19 </li>
20 </ul>
21 </nav>
22 <div class="col-start-4 col-span-6 w-full">
23 <div>
24 <NuxtLink to="/" href="/" class="block text-gray-400 mt-8">← Go Back</NuxtLink>
25 <ul v-if="post.tags" class="flex space-x-3 mt-2">
26 <li
27 class="text-gray-400 font-bold"
28 v-for="tag in post.tags"
29 :key="tag"
30 >
31 {{ tag }}
32 </li>
33 </ul>
34 <h1 class="text-5xl font-black mt-2">{{ post.title }}</h1>
35 </div>
36 <nuxt-content class="mt-4 prose max-w-none" :document="post" />
37 </div>
38 </div>
39 </article>

Firstly, we’re including the post image on top of the page to catch the attention of our reader. Then we add a Table of Content using a special key on our post object that Nuxt Content has added for us called toc. This key is an array with ID’s of every headline that’s part of the body content. We use this to add <NuxtLinks> linking to each headline by their ID:

1<nav class="mt-8 col-span-2 shadow-2xl rounded-lg p-4 self-start">
2 <ul class="space-y-2">
3 <li v-for="link of post.toc" :key="">
4 <NuxtLink
5 class="uppercase tracking-wider text-gray-500 hover:underline"
6 :to="`#${}`"
7 >{{ link.text }}
8 </NuxtLink>
9 </li>
10 </ul>

Next up, we have some code to display a link back to the homepage, the tags of the post, and the title:

2 <NuxtLink to="/" class="block text-gray-400 mt-8">← Go Back</NuxtLink>
3 <ul v-if="post.tags" class="flex space-x-3 mt-2">
4 <li
5 class="text-gray-400 font-bold"
6 v-for="tag in post.tags"
7 :key="tag"
8 >
9 {{ tag }}
10 </li>
11 </ul>
12 <h1 class="text-5xl font-black mt-2">{{ post.title }}</h1>

And finally, a single (but very important!) line of code:

1<nuxt-content class="mt-4 prose max-w-none" :document="post" />

This is where we tell Nuxt Content that we want to render the entire body of each blog post. To test it out, let’s head over to and fill in some example content, right below the frontmatter:

1# h1 Heading
3## h2 Heading
5### h3 Heading
7#### h4 Heading
9##### h5 Heading
11###### h6 Heading
13## Emphasis
15**This is bold text**
17_This is italic text_
21## Blockquotes
23> Example of a very smart quote here
25## Lists
29- Create a list by starting a line with `+`, `-`, or `*`
30- Sub-lists are made by indenting 2 spaces:
31 - Marker character change forces new list start:
32 - Ac tristique libero volutpat at
361. Lorem ipsum dolor sit amet
372. Consectetur adipiscing elit
383. Integer molestie lorem at massa
40## Code
42Inline `code`
44Block code "fences"
47var foo = function (bar) {
48 return bar++
54## Links
56[link text](
58[link with title]( 'title text!')

This is just some example markdown to test how everything looks when we write it as part of a post.

When you save the file and head to your post page, you should see all the newly added content. You’ll also notice that it’s basically unstyled and doesn’t look great. That’s because Tailwind removes most default HTML styling to make it easier to work with and avoid browser inconsistencies.

But luckily, we can install a plugin to provide even better typography defaults. Run:

yarn add @tailwindcss/typography

To use this plugin, create a tailwind.config.js file in the root of your project and add the following:

1module.exports = {
2 purge: [
3 './components/**/*.{vue,js}',
4 './layouts/**/*.vue',
5 './pages/**/*.vue',
6 './plugins/**/*.{js,ts}',
7 './nuxt.config.{js,ts}',
8 ],
9 darkMode: false, // or 'media' or 'class'
10 theme: {
11 extend: {},
12 },
13 variants: {
14 extend: {},
15 },
16 plugins: [require('@tailwindcss/typography')],

All this code is Tailwind specific, so don’t mind if it doesn’t make sense to you. But the key is in the plugins array, where we require our newly installed typography plugin. Restart your dev server and head back to the blog post - you should see the content looking MUCH better with great typographic defaults 🔥

Open Source Session Replay

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue. It’s like having your browser’s inspector open while looking over your user’s shoulder. OpenReplay is the only open-source alternative currently available.


Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Additional content features

Now we got a basic blog up and running, and can start writing real posts using markdown - but like I mentioned earlier, Nuxt Content includes even more cool features.

Live Editing

One of those is called Live Editing, and you can use it by simply double-clicking on the rendered nuxt-content component in the browser. This will open a textarea directly on the page where you can update your content. When you click outside this area, the changes are saved to the actual file. Check out the link to see it in action or try it yourself!

Including HTML and Vue components in posts

Because of Nuxt Content, the markdown files in our content folder basically gets super powers. We can include any random HTML in here if we’d like. In your, add this HTML code:

1<div class="bg-blue-400 w-full h-64 text-white text-4xl flex justify-center items-center">
2 This is a blue box

Now you’ve got a blue box, directly in your blog post. And we can even take it a step further and include entire Vue components! Say you have a Newsletter.vue component that’s a form for readers to sign up to your newsletter. You can easily include that too by just adding:


anywhere in your markdown. NB: The component name has to be kebab-cased and you must include both the opening and closing tag (unlike in .vue files where you could write <Newsletter />)

All put together, you could have a post with this content:

1This is some random intro text
3<div class="bg-blue-400 w-full h-64 text-white text-4xl flex justify-center items-center">
4This is a blue box
7Lorem ipsum dolor sit amet consectetur adipisicing elit. Temporibus nesciunt quidem voluptatem ipsa, doloribus aliquid ipsam sequi? Ipsum architecto, animi sunt quod iste iusto nemo exercitationem impedit odit id voluptatum.

And it would get rendered in the browser as:


Sweet! These features allow us to create more engaging blog posts, since we can basically include anything we’re able to do with Vue right inside our posts 🚀

I’d say we’ve got a pretty solid basic blog setup here! You can write content using markdown, HTML and Vue components, and everything looks nice and smooth. But one thing I often feel is overlooked when building your own blog, is thinking about how you’re actually gonna acquire readers.

Now that your blog is up and running and you start writing content, you wanna make it easy for interested people to find it - and one of the best ways for this is through SEO (Search Engine Optimization).

SEO is basically how Google and other search engines can index your content and present it to people searching for certain keywords or phrases. It’s an entire science, but there’s a few things you can do to boost your SEO. At the same time, we can add some information to make links to our posts look good when sharing it on social media.

Search engines and social media platforms look for this information by parsing the <head> tag of any given website. They look for specific meta tags that we’re gonna add using another feature of Nuxt, the head() function. The head() function works by returning an object that will be injected into the page’s actual <head> tag.

So to do that, we’re gonna add the head() function below asyncData in our _slug.vue file. Make it look like this:

1head() {
2 return {
3 title:,
4 meta: [
5 // Open Graph
6 { hid: 'og:title', property: 'og:title', content: },
7 { hid: 'og:type', property: 'og:type', content: 'article' },
8 {
9 hid: 'og:image',
10 property: 'og:image',
11 content: `${}`,
12 },
13 // Twitter Card
14 {
15 hid: 'twitter:title',
16 name: 'twitter:title',
17 content:,
18 },
19 {
20 hid: 'twitter:card',
21 name: 'twitter:card',
22 content: 'summary_large_image',
23 },
24 {
25 hid: 'twitter:image',
26 name: 'twitter:image',
27 content: `${}`,
28 }
29 ]
30 }

This will set the page title to be equivalent to the title of the post. It’ll also use the post title to set proper meta tags for Open Graph (Facebook) and Twitter. And for both of these, it’ll also set the meta image to be the image from the post. NB: Remember to update to the actual root domain of your own website.

With these in places, Google will have an easier time parsing our content and we’ll have good looking preview cards when sharing links to our posts on social media.

Additional features

Whew, you made it to the end! I’d say we got a pretty solid foundation for our blog going here - but there’s still a bunch of stuff that would make it even better. That’s why I encourage you to play around with some of these additional features and see if you can add them on your own:

  • Add a description to each blog post. This could be used to show 1-2 lines of the post on the preview card. You can also use it to add additional meta tags to make Google even more likely to index your content properly.
  • Add a tag filtering system. Right now we only got 3 posts, but in the future you might have way more. It would be nice if the homepage had a list of all available tags, that the user could use to filter the posts down to their interests.
  • Make it responsive. Our layout currently is only optimized for large screens, so you could add additional styling to make it look great on mobile too.
  • Page transitions. The blog works fine as it is, but it would be cool to have some animation when entering a blog post or going back to the homepage to achieve a better “look & feel”.
  • Build a real site around your new blog. The blog you just built is good, but it would be even better if you could integrate it as a part of your portfolio website with proper navigation, other content etc.
  • Deploy it to Netlify. A blog is no good if nobody can read it - so I strongly encourage you to push the code to GitHub and deploy it live to Netlify for the world to see!


Thank you for reading! I hope you enjoyed the tutorial and learned a thing or two about Nuxt and creating a blog using Nuxt Content and the cool features it has to offer.

To learn more about Nuxt, checkout the official website at

And finally, I’d love to know your thoughts on this article and Nuxt / Nuxt Content in general. So feel free to hit me up on Twitter at @madsbrodt with any feedback you might have.

Until next time!

More articles from OpenReplay Blog

Building a Twitter Sidebar Clone with Material-UI and React

Material-UI is an open-source React component library for building responsive UI applications

August 28th, 2021 · 9 min read

How To Parse and Render Markdown In Vuejs

Markdown is a tool to help you write HTML without the hustle of the tags, however turning it into HTML is not that trivial.

August 27th, 2021 · 5 min read
© 2021 OpenReplay Blog
Link to $ to $ to $