Power your blog with Nuxt Content
Tags: javascript, webdev, nuxt, vue
Published: 2022-07-14 09:49:21

by [Miracle Onyenma](https://blog.openreplay.com/authors/miracle-onyenma)
 
The Nuxt community has finally released the Nuxt Content (or simply Content) Module for Nuxt 3. Content v2 comes with several new features and DX enhancements that make using a File-Based CMS a piece of cake. This is important as it allows developers to manage content and build content-based sites like blogs and documentation without needing to manage a database. 

In this article, we'll look at the Content v2 module, what's new, its use cases and applications, and how we can use it to power our Nuxt 3 blog with a Git/File-Based CMS. We will be building a simple blog, leveraging many of the fundamental and new features provided by Nuxt Content. You can view the [deployed version of what we will be building here](https://content-v2-blog.netlify.app/).

To follow along you should have:

- A basic understanding of HTML, CSS & JS, Vue and the [Markdown syntax](https://www.markdownguide.org/basic-syntax/)
- [**Node.js**](https://nodejs.org/en/download/)[*](https://nodejs.org/en/download/) [(latest LTS version)](https://nodejs.org/en/download/)
- [**Visual Studio Code**](https://code.visualstudio.com/)
- [**Volar Extension**](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar), and either enable [**Take Over Mode**](https://github.com/johnsoncodehk/volar/discussions/471) [](https://github.com/johnsoncodehk/volar/discussions/471) (recommended) or add [**TypeScript Vue Plugin (Volar)**](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin)
- [VS Code](https://marketplace.visualstudio.com/items?itemName=Nuxt.mdc) [**MDC Extension**](https://marketplace.visualstudio.com/items?itemName=Nuxt.mdc)
- A terminal; I recommend using VS Code's integrated terminal

## Jamstack and Git/File-based CMSs

If you've heard of [Jamstack](https://jamstack.org/), you should be familiar with how it has changed how we build modern websites and web applications. Jamstack uses static-site generators to offer prerendered markup, JavaScript for functionality, and APIs for data to create faster websites. You can learn more about it from [the official Jamstack site](https://jamstack.org/resources).

There are many ways to provide content for a Jamstack site. Many websites use APIs that to get content pulled from a remote database. Most times, a headless CMS like [Contentful](https://www.contentful.com/) provides these APIs. Server-Side Rendering tools like [Gatsby](https://www.gatsbyjs.org/) then fetch this data at build time and render the pages as HTML files.

Instead of a [Headless CMS](https://strapi.io/what-is-headless-cms) to provide content for the site, we can use template files like Markdown. Popular [static site generators](https://www.staticgen.com/) use template files to provide content. Git/File based CMSs provide a way to manage the content in these template files and provide it to the frontend at build time without a database. The Nuxt Content module acts as a Git/File-based CMS for your SSR-enabled Nuxt application, allowing you to write your content in Markdown, YML, CSV, or JSON and query it in your components.

## Nuxt Content Module

The [Content Module](https://content.nuxtjs.org/) reads the content/ directory in your Nuxt project, parses .md, .yml, .csv and .json files to create a powerful data layer for your application. It also allows you to use Vue components in Markdown with the [MDC syntax](https://content.nuxtjs.org/guide/writing/mdc).

Take a look at some of the new features of Content v2 highlighted on the [official website](https://content.nuxtjs.org/)

- **Built for Nuxt 3**: Take advantage of Nuxt 3 features: Vue 3, Auto-imports, Vite, and Nitro server.
- **File-based CMS:** Write your content in Markdown, YML, CSV, or JSON and query it in your components.
- **Query Builder:** Query your content with a MongoDB-like API to fetch the right data at the right time.
- **MDC Syntax:** Use your Vue components in Markdown files, supporting props, slots, and nested components.
- **Code highlighting:** Display beautiful code blocks on your website with the Shiki integration supporting VS Code themes.
- **Deploy everywhere:** Nuxt Content supports both static or Node server hosting.

An important feature introduced in this new version of Nuxt Content, apart from the fact that it now supports Nuxt 3, is the addition of the [MDC syntax](https://content.nuxtjs.org/guide/writing/mdc) which allows you to add Vue components to your .md files. Nuxt Content v1 supports Vue components, but v2 comes with a slightly different syntax and more improvements.

We can use the Nuxt Content module to build any static site that requires content. It works great for websites like documentation and blogs with static content but not so great for websites where most of its content is dynamic or changes in real-time since the site will have to be built for changes in the file to take effect. It also works great for other static sites with dynamic content on the client-side with JavaScript. Sites like landing pages, portfolios, event sites, company websites, and more are good use cases for Nuxt Content.

Let's dive in and see how we can build a site of our own with Nuxt Content.

## Building our blog

We can create a new Nuxt content project or add it to an existing Nuxt 3 project. You can start a new Nuxt Content project with:

```
# with npm
npx nuxi init content-blog -t content
# with pnpm
pnpm dlx nuxi init content-blog -t content
```

Navigate to the newly created `./content-blog`  folder and install the dependencies:

```
# with yarn
yarn install
# with npm
npm install
# with pnpm
pnpm install --shamefully-hoist
```

Now you'll be able to start your Nuxt content app in development mode:

```
# with yarn
yarn dev
# with npm
npm run dev
# with pnpm
pnpm run dev
```

Awesome! A browser window should automatically open for [http://localhost:3000](http://localhost:3000/). 

You can add Nuxt Content at any time to an existing Nuxt 3 project by installing the `@nuxt/content` module:

```
# with yarn
yarn add --dev @nuxt/content
# with npm
npm install --save-dev @nuxt/content
# with pnpm
pnpm add -D @nuxt/content
```

Then, add `@nuxt/content` to the `modules` section of `nuxt.config.ts`:

```
// ./nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  modules: ['@nuxt/content'],
  content: {
    // https://content.nuxtjs.org/api/configuration
  }
})
```

> ⚠️ Content v2 requires [Nuxt 3](https://v3.nuxtjs.org/). If you are using Nuxt 2, check out [Content v1 documentation](https://content.nuxtjs.org/v1/getting-started/installation)

Let’s quickly install and setup [Tailwind](https://tailwindcss.com/), [Tailwind typography](https://tailwindcss.com/docs/typography-plugin), and [Hero Icons](https://heroicons.com/) to style our project. We’ll be using the `[@nuxt/tailwind](https://tailwindcss.nuxtjs.org/)` [module](https://tailwindcss.nuxtjs.org/). We'll also install the tailwind form plugin:

```
# with yarn
yarn add --dev @nuxtjs/tailwindcss @tailwindcss/typography @heroicons/vue
# with npm
npm install --save-dev @nuxtjs/tailwindcss @tailwindcss/typography @heroicons/vue
# with pnpm
pnpm add -D @nuxtjs/tailwindcss @tailwindcss/typography @heroicons/vue
```

Add it to the `modules` section in `nuxt.config.ts`:

```
// nuxt.config.ts
// ...
export default defineNuxtConfig({
  modules: ['@nuxt/content', '@nuxtjs/tailwindcss'],
  content: {
    // https://content.nuxtjs.org/api/configuration
  }
})
```

Create `tailwind.config.js` by running:

```
npx tailwindcss init
```

Add Tailwind typography plugin to `tailwind.config.js`

```javascript
// tailwind.config.js
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}
```

Next, let’s create our `/.assets/css/main.css` file:

```
/* ./assets/css/main.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
```

[View full code on GitHub](https://github.com/miracleonyenma/content-v2-blog/blob/main/assets/css/main.css)

in the `nuxt.config.ts` file, enter the following:

```javascript
// ./nuxt.config.ts
// ...
export default defineNuxtConfig({
  // ...
  tailwindcss: {
    cssPath: '~/assets/css/main.css',
  }
})
```

Start the app

```
# with yarn
yarn dev
# with npm
npm run dev
# with pnpm
pnpm dev
```

> 🚨 If you encounter an [issue similar to the one described in this issue](https://github.com/nuxt/content/issues/1237), you can go through the conversation there to fix it, at least until the next update.

## Create the first post

Here's what our project structure should look like at this point:

```
content-blog
├─ .gitignore
├─ app.vue
├─ assets
│  └─ css
│     └─ main.css
├─ content
│  ├─ about.md
│  └─ index.md
├─ nuxt.config.ts
├─ package.json
├─ pages
│  └─ [...slug].vue
├─ pnpm-lock.yaml
├─ README.md
├─ tailwind.config.js
└─ tsconfig.json
```

However, if you are adding Nuxt Content to an existing Nuxt 3 project, you’ll have to manually create the `./content` folder and create a `./content/index.md` file:

```
<!-- ./content/index.md -->
# Hello Content v2
This page corresponds to the `/` route of your website. You can delete it or create another file in the `content/` directory. 
Try to navigate to \[/what\](/what). These 2 pages are rendered by the `pages/[...slug].vue` component.
---
Look at the \[Content documentation\](https://content-v2.nuxtjs.org/) to learn more.
```

Next, in order to display the content, create a `./pages/[…slug].vue` which [catches all routes](https://v3.nuxtjs.org/guide/directory-structure/pages/#catch-all-route) and renders the `<ContentDoc />` component.

```
<!-- ./pages/[…slug].vue -->

<template>
  <main>
    <article class="prose">
      <ContentDoc />
    </article>
  </main>
</template>
```

`<ContentDoc>` and `<ContentRenderer>` are two components provided by Content that render the body of a Markdown document in a rich-text format. If we navigate to `http://localhost:3000/`, we should get this:

![Rendered document on home page](https://blog.openreplay.com/static/63af3f792d247cc6a475ea301b1b46e4/68382/image01.png)

With `<ContentDoc>`, the fetching endpoint defaults to the current route (`$route.path`). That way, if we navigate to `/about`, it automatically renders the content in the `./content/about.md` file. Create the `./content/about.md` file:

```
<!-- ./content/about.md -->

# About page
You can go back to the \[home page\](/).
```

Now, if we click on the `/about` link on the home page, it'll route and display the rendered content from `./content/about.md`.

![Rendered document on about page](https://blog.openreplay.com/static/091950c01d746cb87d26e4c418c041b5/1e4f6/image02.png)

Awesome. Now that we've seen how that works, let's structure our Nuxt application. First, replace the `./pages/[…slug].vue` page with `./pages/index.vue`, which will be the application's home page.

```
<!-- ./pages/index.vue -->
<template>
  <main>
    <section class="hero-section">
      <header>
        <h1 class="font-black text-8xl">Welcome to my site</h1>
        <p>Take a look at <NuxtLink to="/blog">my blog</NuxtLink></p>
      </header>
    </section>
  </main>
</template>

<style scoped> /* ... */ </style>
```

Next, create a new file `./pages/blog/[…slug].vue`.

```
<!-- ./pages/blog/[…slug].vue -->

<template>
  <main>
    <article class="prose p-16 max-w-3xl m-auto">
      <ContentDoc />
    </article>
  </main>
</template>
```

Notice the `.prose`  class in `<article class="prose">`; this gives us basic typography styling for our article content. Now, let’s create our first blog post, `./content/blog/first-post.md`.

```
<!-- ./content/blog/first-post.md -->

# My first blog post
Welcome to my first blog post using content v2 module
```

Here's what our first blog post should look like:

![Default page meta from markdown](https://blog.openreplay.com/static/2870f8017eff09434eb0f5ec9081ff0c/68382/image03.png)

As you can see in the Devtools, the `<title>` of the page corresponds to the first `<h1>` of the page (or first `#` in the Markdown page). The `<meta name="description">` corresponds to the first `<p>` in the page. These are the default values gotten from the Markdown content. Let's look at how we can further customize this using Markdown in Nuxt Content.

## Using Markdown

So far, we've seen that with the `<ContentDoc />` component, we can transform our markdown to valid HTML and style it using TailwindCSS Typography. Nuxt Content uses [Prose components](https://content.nuxtjs.org/api/components/prose) to render HTML tags from Markdown syntax. These components can further be customized as you see fit. TailwindCSS Typography also targets these Prose elements and styles them accordingly.

The `[ProseA](https://content.nuxtjs.org/api/components/prose#prosea)` component [can be customized](https://content.nuxtjs.org/guide/writing/markdown#customizing) by creating a component file with the same name in your `./components/content/` directory. You have to use the same props in the [original component](https://github.com/nuxt/content/tree/main/src/runtime/components/Prose/ProseA.vue) for the component to work.

Let’s add some more content to our post - `./content/blog/first-post.md` to see a few of these components in action:

```
    <!-- ./content/blog/first-post.md -->

    # My first blog post
    Welcome to my first blog post using \[content v2 module\](https://content.nuxtjs.org/)
    Hey there! 👋🏾
    This is my first blog post learning nuxt content.
    I'm currently building it using the following:
    - Nuxt.js
    - Nuxt Content module
    - TailwindCSS
      - TailwindCSS typography

    ## Nuxt.js
    \[Nuxt\](https://nuxtjs.org/) is a powerful Vue framework that offers excellent development features such as server-side rendering.

    ```
    npx nuxi init nuxt-app
    cd nuxt-app
    yarn install
    yarn dev -o
    ```

    ```javascript
    // ./nuxt.config.ts
    import { defineNuxtConfig } from 'nuxt'
    export default defineNuxtConfig({
      // My Nuxt config
    })
    ```

    ## Nuxt content module
    Empower your NuxtJS application with \[@nuxt/content module\](https://content.nuxtjs.org/): write in a content/ directory and fetch your Markdown, JSON, YAML, XML, and CSV files through a MongoDB-like API, acting as a Git-based Headless CMS.

    You can get started with Nuxt Content by installing a new project.

    ```
    npx nuxi init content-app -t content
    ```

    ## TailwindCSS
    Rapidly build modern websites without ever leaving your HTML. \[TailwindCSS\](https://tailwindcss.com/) is A utility-first CSS framework packed with classes like `flex`, `pt-4`, `text-center`, and `rotate-90` that can be composed to build any design directly in your markup.

    ### TailwindCSS Typography
    \[Typography\](https://tailwindcss.com/docs/typography-plugin) is 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).
```

Once saved, we should see the changes and the rendered HTML with styling applied.

![Rendered document with styling](https://blog.openreplay.com/static/9d4d0f6ebe0180e79e104989ae40502f/68382/image04.png)

You can find more [details on Prose in the Nuxt Content docs](https://content.nuxtjs.org/guide/writing/markdown#prose).

## Syntax Highlighting

One very important Prose component we’ll look at is the `[ProseCode](https://content.nuxtjs.org/api/components/prose#prosecode)` component and `ProseCodeInline`  component. Nuxt Content gives us syntax highlighting out-of-the-box with [Shiki](https://github.com/shikijs/shiki). To apply themes to our code blocks, we have to configure `./nuxt.config.ts`

```javascript
// ./nuxt.config.ts

import { defineNuxtConfig } from 'nuxt'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  // ...
  content: {
    // https://content.nuxtjs.org/api/configuration
    highlight: {
      theme: {
        // Default theme (same as single string)
        default: 'material-palenight',
        // Theme used if `html.dark`
        dark: 'github-dark',
      }
    }
  },
  // ...
})
```

Once the server restarts, we can see the default theme applied:

![VSCode theme applied to code blocks](https://blog.openreplay.com/static/e88f7f56dfdd0ba26cf996e1ac9bddf1/b85b2/image05.png)

You can find [more themes supported out-of-the-box](https://github.com/shikijs/shiki/blob/main/docs/themes.md). You can also add other VSCode themes.

## Customize page meta with Front-matter

We can also add and modify meta-data by adding a block of YAML front matter to our markdown file.

```
    ---
    title: 'Nuxt Content v2 is awesome!'
    description: 'This is my first article!'
    img: 'img/cover (1).JPG'
    tags: [Nuxt, Content, Learning]
    ---
```

This will be displayed on the page `<head>` instead of the default values.

![Custom meta with Front matter](https://blog.openreplay.com/static/98bf5c44bad319e294fab92bcfce1eb0/b85b2/image06.png)

We can achieve much more with Front-matter, which we'll dive into soon enough.

## Vue components with MDC

MDC stands for MarkDown Components. It's a syntax we can use to insert Vue components directly in our Markdown. Any component in our application's `components/content/` directory or [made available globally](https://v3.nuxtjs.org/guide/directory-structure/components) can be used in Markdown files. To get started, create a new component in `./components/content/InfoBox.vue`

```
<!-- ./components/content/InfoBox.vue -->

<script setup>
// import icons from HeroIcons
import { InformationCircleIcon, ExclamationIcon, BanIcon } from "@heroicons/vue/solid";
// define props in <script>
const props = defineProps(["type"]);
</script>
<template>
  <!-- Access `type` prop in Dynamic class  -->
  <div class="info-box not-prose" :class="[type]">
    <!-- Conditionally render icons based on prop -->
    <ExclamationIcon v-if="type == 'warning'" class="icon solid" />
    <BanIcon v-else-if="type == 'error'" class="icon solid" />
    <InformationCircleIcon v-else class="icon solid" />
    <details>
      <summary>
        <!-- Unamed Slot to render component content -->
        <slot />
      </summary>
      <div class="details pt-2">
        <!-- Named markdown component to render rich-text -->
        <Markdown :use="$slots.details" unwrap="p"></Markdown>
      </div>
    </details>
  </div>
</template>
<style scoped> /* ... */ </style>
```

[View the complete code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/components/content/InfoBox.vue)

Here we have a simple component that takes in `type`  as a prop. In the `<template>`, we assign the `type`  prop to a dynamic class in the `div.info-box` element. With this, whatever string we pass to the `type` prop from our Markdown will take effect in our component.
We also have a few icons conditionally rendered based on the value of the `type` prop.

[To render component content](https://content.nuxtjs.org/guide/writing/mdc#block-components), the component must contain either:

- A `<slot />` to accept raw text or another component.
- The `<Markdown />` component to accept formatted text

Now, we're using an unnamed `<slot/>`  element to render content right inside a a `<summary/>` element. Next, we use named `<Markdown />` component to render some extra content right inside a `div.details` element. We use the `**::**` identifier to use a component in our Markdown file. In the `./content/blog/first-post.md` file add the following code:

```
    <!-- ./content/blog/first-post.md -->
    <!-- ... -->
    ::InfoBox{type="error"}
    Here's a handy bit of information for you!

    #details
    This will be rendered inside the `description` slot. _It's important_ to see how this **works**.
    \[More information can be found here\](#)
    ::
    <!-- ... -->
```

[You can view the code here](https://github.com/miracleonyenma/content-v2-blog/blob/bb0ad300e54a6b0edbcd0e9733e3ad931a757bce/content/blog/first-post.md?plain=1#L41)

Here, you can see that:

- The `{}` identifier passes [props](https://content.nuxtjs.org/guide/writing/mdc#props) to components in a terse way by using a `key=value` syntax.
- The **default** slot renders the top-level content inside the block component.
- **named** slots use the `#` identifier to render the content.

Here's our component in action:

![Markdown component in action](https://blog.openreplay.com/580e3afd6753a84a08760b154decac53/image07.gif)

But that's just the tip of the iceberg of what you can do with MDC. You can find more useful functionalities like [Nested components](https://content.nuxtjs.org/guide/writing/mdc#nesting), [Inline components](https://content.nuxtjs.org/guide/writing/mdc#inline-components) and [spans](https://content.nuxtjs.org/guide/writing/mdc#span-text), [YAML props](https://content.nuxtjs.org/guide/writing/mdc#yaml-method), and many more in [the MDC Syntax page of the Nuxt Content docs.](https://content.nuxtjs.org/guide/writing/mdc)

## Content API

The new Nuxt Content allows you to create an entire website entirely from Markdown files with a new **Document-driven** mode. Document-driven mode creates a direct binding between your `content/` directory and your pages. Content can now make queries from [route middlewares](https://v3.nuxtjs.org/guide/ directory-structure/middleware#middleware-directory). These queries resolve before your page renders and will give access to the `[useContent()](https://content.nuxtjs.org/api/composables/use-document-driven)` composable anywhere in your app. You can find [more information about the Document-Driven mode in the Nuxt Content Docs](https://content.nuxtjs.org/guide/writing/document-driven).

In our case, however, to add a blog to our site in cases where we might be trying to add a blog to an existing site, we would use the `queryContent()` function to dynamically fetch content from the `./content/` directory within any page or component of our application. 
We also have access to [REST API endpoints automatically generated by Nuxt Content](https://content.nuxtjs.org/guide/displaying/querying#api-routes) to fetch documents from the `./content/` directory. The API root path is - `/api/_content/query`. It accepts parameters such as:

- `path` - `/api/_content/query?path=/hello`
    Gets the document with the defined `path` - `./content/hello.md`

- `only` - `/api/_content/query?only=title`
    Get the titles of the documents

- `sort` - `/api/_content/query?sort=size:1`
    Sort the list of documents

- `without` - `/api/_content/query?without=body`
    Fetch documents without including the `body` in the response

We'll use more of the `queryContent()` function to fetch our documents. Let’s modify `./pages/blog/[…slug.vue]` to use `queryContent()` to fetch our articles:

```javascript
<!-- ./pages/blog/[…slug.vue] -->

<script setup>
const { path } = useRoute();
const { data } = await useAsyncData(`content-${path}`, async () => {

  // fetch document where the document path matches with the cuurent route
  let article = queryContent().where({ _path: path }).findOne();

  // get the surround information,
  // which is an array of documeents that come before and after the current document
  let surround = queryContent().only(["_path", "title", "description"]).sort({ date: 1 }).findSurround(path);
  return {
    article: await article,
    surround: await surround,
  };
});

// destrucure `prev` and `next` value from data
const [prev, next] = data.value.surround;

// set the meta
useHead({
  title: data.value.article.title,
  meta: [
    { name: "description", content: data.value.article.description },
    {
      hid: "og:image",
      property: "og:image",
      content: `https://site.com/${data.value.article.img}`,
    },
  ],
});
</script>
<template>
  <main id="main" class="article-main">
    <header v-if="data.article" class="article-header">
      <div class="img-cont h-72 mb-12">
        <img :src=`/${data.article.img}` :alt="data.article.title" class=" rounded-2xl" />
      </div>
      <h1 class="heading">{{ data.article.title }}</h1>
      <p class="supporting">{{ data.article.description }}</p>
      <ul class="article-tags">
        <li class="tag" v-for="(tag, n) in data.article.tags" :key="n">{{ tag }}</li>
      </ul>
    </header>
    <hr />
    <section class="article-section">
      <aside class="aside">
        <!-- Toc Component -->
      </aside>
      <article class="article">
        <!-- render document coming from query -->
        <ContentRenderer :value="data.article">
          <!-- render rich text from document -->
          <MarkdownRenderer :value="data.article" />
          <!-- display if document content is empty -->
          <template #empty>
            <p>No content found.</p>
          </template>
        </ContentRenderer>
      </article>
    </section>
    <!-- PrevNext Component -->
  </main>
</template>
<style scoped> /* ... */ </style>
```

[You can view the full code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/pages/blog/%5B...slug%5D.vue) You can see that we're no longer using the `<ContentDoc />` component to render our document. In `<script>`, we’re using `useAsyncData` to fetch the document with `queryContent()` based on the current path gotten from `useRoute()`. Within `useAsyncData`, we're making queries for two things:

- `article` is the document content
- `surround` is an array of two documents that come before and after the current document. We'll use this to build our previous and next article functionality.

## Set page Meta with `useHead()`

Also, it's important to note that we are now populating the page `<head>` ourselves using `useHead()`. Something `<ContentDoc />` did automatically. Now, we can use Nuxt's `useHead()` method to add metadata to our `<head>`.

```javascript
// set the meta
useHead({
  title: data.value.article.title,
  meta: [
    { name: "description", content: data.value.article.description },
    {
      hid: "og:image",
      property: "og:image",
      content: `https://site.com/${data.value.article.img}`,
    },
  ],
});
```

[View the code here](https://github.com/miracleonyenma/content-v2-blog/blob/bb0ad300e54a6b0edbcd0e9733e3ad931a757bce/pages/blog/%5B...slug%5D.vue#L22)

You can see it in action here:

![Image meta set with useHead](https://blog.openreplay.com/static/52f7bed63ca458caec1bd2777463c0a5/b6117/image08.png)



## Open Source Session Replay

_[OpenReplay](https://github.com/openreplay/openreplay) is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data._

![replayer.png](https://static.openreplay.com/images/banner-blog.png)

_Start enjoying your debugging experience - [start using OpenReplay for free](https://github.com/openreplay/openreplay)._




## Build Table of Content Component

Nuxt Content also provides a neat and customizable table of content data or simply `toc`. To build the component, create a new file `./components/Toc.vue.`

```
<!-- ./components/Toc.vue -->

<script setup>
// define links prop
defineProps(["links"]);

// flatten TOC links nested arrays to one array
const flattenLinks = (links) => {
  let _links = links
    .map((link) => {
      let _link = [link];
      if (link.children) {
        // recursively flatten children links
        let flattened = flattenLinks(link.children);
        _link = [link, ...flattened];
      }
      return _link;
    })
    .flat(1);
  return _links;
};

</script>
<template>
  <nav class="toc">
    <header class="toc-header">
      <h3 class="text-xl font-bold">Table of contents</h3>
    </header>
    <ul class="toc-links">
      <!-- render each link with depth class -->
      <li v-for="link of flattenLinks(links)" :key="link.id" :class=`toc-link _${link.depth}`>
        <a :href=`#${link.id}`>
          {{ link.text }}
        </a>
      </li>
    </ul>
  </nav>
</template>
<style scoped> /* ... */ </style>
```

[View full code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/components/Toc.vue) 

By default the `toc` object returned by `queryContent()` contains nested items within a `children` key. Here's an example:

```
{
    "title": "",
    "searchDepth": 5,
    "depth": 5,
    "links": [
        {
            "id": "my-first-blog-post",
            "depth": 2,
            "text": "My first blog post"
        },
        {
            "id": "nuxtjs",
            "depth": 2,
            "text": "Nuxt.js"
        },
        {
            "id": "nuxt-content-module",
            "depth": 2,
            "text": "Nuxt content module"
        },
        {
            "id": "tailwindcss",
            "depth": 2,
            "text": "TailwindCSS",
            "children": [
                {
                    "id": "tailwindcss-typography",
                    "depth": 3,
                    "text": "TailwindCSS Typography"
                }
            ]
        }
    ]
}
```

You can see that the " tailwindcss " link object contains" children, " which contains an array of links. To flatten the entire `toc.links` array into one flat array, we created a simple helper function `flattenLinks()`. We can now plug it into the page. In the `./pages/blog/[…slug].vue` page, add the component and pass the `links` prop.

```
<!-- ./pages/blog/[…slug.vue] -->
<!-- ... -->

<template>
  <main id="main" class="article-main">
    <header v-if="data.article" class="article-header"> <!-- ... --> </header>
    <hr />
    <section class="article-section">
      <aside class="aside">
        <!-- Toc Component -->
        <Toc :links="data.article.body.toc.links" />
      </aside>
      <article class="article"> <!-- ... --> </article>
    </section>
  </main>
</template>
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/f435cd0499d3c32ae696b7e4ef3ae83d6a043391/pages/blog/%5B...slug%5D.vue#L50)

Here's what our component should look like

![Table of content component](https://blog.openreplay.com/static/16e1bc67d9fa6d6e1bc88931f636baa7/c533c/image09.png)

## Build Previous and next article component

Let's see how we can create another component to help users navigate between posts easily. Create a new file `./components/PrevNext.vue`

```
<!-- ./components/PrevNext.vue -->

<script setup>
// import icons
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/vue/outline";
// define prev and next props
defineProps(["prev", "next"]);
</script>
<template>
  <ul class="prev-next-cont">
    <li class="link-item prev">
      <NuxtLink v-if="prev" :to="prev._path">
        <ArrowLeftIcon class="icon stroke" />
        <span> {{ prev.title }} </span>
      </NuxtLink>
    </li>
    <li class="link-item next">
      <NuxtLink v-if="next" :to="next._path">
        <span> {{ next.title }} </span>
        <ArrowRightIcon class="icon stroke" />
      </NuxtLink>
    </li>
  </ul>
</template>
<style scoped> /* ... */ </style>
```

[View full code here](https://github.com/miracleonyenma/content-v2-blog/blob/main/components/PrevNext.vue)

Now, we can pass the `prev` and `next` data to the component in `./pages/[slug].vue`

```
<!-- ./pages/blog/[…slug.vue] -->

<template>
  <main id="main" class="article-main">
    <header v-if="data.article" class="article-header"> <!-- ... --> </header>
    <hr />
    <section class="article-section"> <!-- ... --> </section>
    <!-- PrevNext Component -->
    <PrevNext :prev="prev" :next="next" />
  </main>
</template>
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/f435cd0499d3c32ae696b7e4ef3ae83d6a043391/pages/blog/%5B...slug%5D.vue#L67)

Let's create a few more articles and remove the documents that are no longer needed to see this in action. Here's what our `./content` folder should look like this now:

```
├─ content
│  └─ blog
│     ├─ first-post.md
│     ├─ second-post.md
│     └─ third-post.md
```

And here's the `<PrevNext />` component in our second blog post. You can see that the previous post is our first post, and the next post is our third post.

![Previous and next component displaying first and third post](https://blog.openreplay.com/static/ca480740327e3563bca7db800b8462b8/46abd/image10.png)

> 🎖 Nuxt content also allows you to order your content by adding numeric prefixes to files. [Read more on ordering in the Nuxt Content docs](https://content.nuxtjs.org/guide/writing/content-directory#ordering)

## List all articles

To list all the articles,  create a new file `./pages/blog/index.vue`. We'll be using the `<ContentList />` component provided by Nuxt Content to list all the documents in the `./content/blog` path.

```
<!-- ./pages/blog/index.vue -->
<script setup>
// set meta for page
useHead({
  title: "All articles",
  meta: [{ name: "description", content: "Here's a list of all my great articles" }],
});
</script>
<template>
  <main>
    <header class="page-heading">
      <div class="wrapper">
        <h1 class="text-5xl font-extrabold">All articles</h1>
        <p class="font-medium text-lg">Here's a list of all my great articles</p>
      </div>
    </header>
    <section class="page-section">
      <!-- Render list of all articles in ./content/blog using `path` -->
      <!-- Provide only defined fields in the `:query` prop -->
      <ContentList
        path="/blog"
        :query="{
          only: ['title', 'description', 'tags', '_path', 'img'],
        }"
      >
        <!-- Default list slot -->
        <template v-slot="{ list }">
          <ul class="article-list">
            <li v-for="article in list" :key="article._path" class="article">
              <NuxtLink :to="article._path">
                <div class="wrapper">
                  <div class="img-cont w-32">
                    <img :src=`/${article.img}` :alt="article.title" class="rounded-lg max-h-[8rem]" />
                  </div>
                  <header>
                    <h1 class="text-2xl font-semibold">{{ article.title }}</h1>
                    <p>{{ article.description }}</p>
                    <ul class="article-tags">
                      <li class="tag !py-0.5" v-for="(tag, n) in article.tags" :key="n">{{ tag }}</li>
                    </ul>
                  </header>
                </div>
              </NuxtLink>
            </li>
          </ul>
        </template>

        <!-- slot to display message when no content is found -->
        <template #not-found>
          <p>No articles found.</p>
        </template>
      </ContentList>
    </section>
  </main>
</template>
<style scoped> /* ... */ </style>
```

[View full code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/pages/blog/index.vue)

Here, we're passing two parameters to `<ContentList />`:

- `path`: The path of the content to load from the content source.
    Here, we specified `/blog` since we want to display content from the `/blog` directory.
- `query`: Query to be passed to `queryContent()`. Since this component uses `queryContent()` internally, we can pass queries to fine-tune results.
    For example, we used the `only` query to fetch only a few fields returned by the component.

We used `<template` `*v-slot*``="{ list }">`  to display content and `<template` `*#not-found*``>` for fallback when no content is found. We should have something like this:

![All articles displayed](https://blog.openreplay.com/static/a2acbd13a8d23961835da573642ea436/46abd/image11.png)

Awesome. Now let's quickly create a `<SiteHeader />` component for our blog to help us navigate to this page easily.

## Create Site header component

Create a new file `./components/SiteHeader.vue`

```
<!-- ./components/SiteHeader.vue -->
<template>
  <header class="site-header">
    <div class="wrapper">
      <NuxtLink to="/" class="no-underline">
        <figure class="site-logo">
          <h1>Site</h1>
        </figure>
      </NuxtLink>
      <nav class="site-nav">
        <ul class="links">
          <li class="link">
            <NuxtLink to="/blog">Blog</NuxtLink>
          </li>
        </ul>
      </nav>
    </div>
  </header>
</template>
<style scoped> /* ... */ </style>
```

[View full code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/components/SiteHeader.vue)

Now, add the component in `./app.vue`

```
<!-- ./app.vue -->
<template>
  <div>
    <SiteHeader />
    <NuxtPage />
  </div>
</template>
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/main/app.vue)

## Display articles by tags

One last thing we can add to our blog is the ability to display articles under certain conditions. We'll create a dynamic `tags` page we'll use to display articles by their tags.
Create a new slug page `./pages/blog/tags/[slug].vue`

```
<!-- ./pages/blog/tags/[slug].vue -->

<script setup>
// get current route slug
const {
  params: { slug },
} = useRoute();

// get array of filters by generating array from separating slug`,`
const filter = slug.split(",");

// set meta for page
useHead({
  title: `All articles with ${slug}`,
  meta: [{ name: "description", content: "Here's a list of all my great articles" }],
});
</script>
<template>
  <main>
    <header class="page-heading">
      <div class="wrapper">
        <h1 class="text-5xl font-extrabold">All articles with "{{ slug }}"</h1>
        <p class="font-medium text-lg">Here's a list of all my great articles</p>
      </div>
    </header>
    <section class="page-section">
      <!-- Render list of all articles in ./content/blog using `path` -->
      <!-- Provide only defined fieldsin the `:query` prop -->
      <ContentList
        path="/blog"
        :query="{
          only: ['title', 'description', 'tags', '_path', 'img'],
          where: {
            tags: {
              $contains: filter,
            },
          },
          $sensitivity: 'base',
        }"
      >
        <!-- Default list slot -->
        <template v-slot="{ list }">
          <ul class="article-list">
            <li v-for="article in list" :key="article._path" class="article-item">
              <NuxtLink :to="article._path">
                <div class="wrapper">
                  <div class="img-cont w-32">
                    <img :src=`/${article.img}` :alt="article.title" class="rounded-lg max-h-[8rem]" />
                  </div>
                  <header>
                    <h1 class="text-2xl font-semibold">{{ article.title }}</h1>
                    <p>{{ article.description }}</p>
                    <ul class="article-tags">
                      <li class="tag" v-for="(tag, n) in article.tags" :key="n">
                        <NuxtLink :to=`/blog/tags/${tag}` class="underline"> {{ tag }} </NuxtLink>
                      </li>
                    </ul>
                  </header>
                </div>
              </NuxtLink>
            </li>
          </ul>
        </template>
        <!-- Not found slot to display message when no content us is found -->
        <template #not-found>
          <p>No articles found.</p>
        </template>
      </ContentList>
    </section>
  </main>
</template>
```

[View full code here](https://github.com/miracleonyenma/content-v2-blog/blob/main/pages/blog/tags/%5Bslug%5D.vue)

This page is very similar to our `./pages/blog/index.vue` page. In this page, we added a `:query` prop and passed this query:

```javascript
{
  only: ['title', 'description', 'tags', '_path', 'img'],
  where: {
    tags: {
      $contains: filter,
    },
  },
  $sensitivity: 'base',
}
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/f435cd0499d3c32ae696b7e4ef3ae83d6a043391/pages/blog/tags/%5Bslug%5D.vue#L37)

This query allows us to list articles with tags containing the one specified in `filter`.
Now, to ensure that users can easily navigate through the website and explore every tag, we need a way to get all the tags from all the articles.
To do this, we'll have to create a `<Tags />` component.

## Create a Tags component

This component will use `queryContent()` to fetch the tags from every article and display them as links that will route to the `/tags/[slug]` page with the tag as the slug. Create a new component `./components/Tags.vue`:

```
<!-- ./components/Tags.vue -->

<script setup>
// import icon
import { TagIcon } from "@heroicons/vue/solid";

// tag list state
const expanded = ref(false);

// helper function to flatten tags array
const flatten = (tags, key) => {
  let _tags = tags
    .map((tag) => {
      let _tag = tag;
      if (tag[key]) {
        let flattened = flatten(tag[key]);
        _tag = flattened;
      }
      return _tag;
    })
    .flat(1);
  return _tags;
};

// function to toggle expanded state
const toggleExpand = () => {
  expanded.value = !expanded.value;
};

// get only tags data from `/blog`
const { data } = await useAsyncData("tags", () => queryContent("blog").only(["tags"]).find());

// generate array without duplicates from flattened array
const articleTags = [...new Set(flatten(data.value, "tags"))];
</script>
<template>
  <div class="tag-list" :class="{ active: expanded }">
    <!-- Button to toggle expand -->
    <button @click="toggleExpand" class="cta w-icon">
      <TagIcon class="icon solid" />
      <span>Tags</span>
    </button>
    <ul class="article-tags" :class="{ expanded: expanded }">
      <!-- list out tags with links -->
      <li v-for="(tag, n) in articleTags" :key="n" class="tag">
        <NuxtLink :to=`/blog/tags/${tag}` class="font-semibold"> {{ tag }} </NuxtLink>
      </li>
    </ul>
  </div>
</template>
<style scoped>/* ... */</style>
```

[View full code with styles here](https://github.com/miracleonyenma/content-v2-blog/blob/main/components/Tags.vue)

Here, we use `queryContent()` to fetch the tags from all articles, we flatten it using the `flatten()` helper function, generate an array from a `Set` of the flattened array to remove duplicates, and assign it to `articleTags`.
Then, in the `<template>`,  we render a list of `<NuxtLink />` which routes to `/blog/tags/${tag}`. If we click on a tag of `Nuxt`, it'll route us to `/blog/tags/Nuxt`, which lists out all the articles with that tag on that page.

Add this component to the `./pages/blog/index.vue` page

```
<!-- ./pages/blog/index.vue -->

<template>
  <main>
    <header class="page-heading"> <!-- ... --> </header>
    <section class="page-section">
      <Tags />
      <!-- ... -->
    </section>
  </main>
</template>
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/3935af52254d6198f6f6ea77f3e4ce408cc6c028/pages/blog/index.vue#L30)

and the `./pages/blog/tags/[slug].vue` page.

```
<!-- ./pages/blog/tags/[slug].vue -->

<template>
  <main>
    <header class="page-heading"> <!-- ... --> </header>
    <section class="page-section">
      <Tags />
      <!-- ... -->
    </section>
  </main>
</template>
```

[View code here](https://github.com/miracleonyenma/content-v2-blog/blob/f435cd0499d3c32ae696b7e4ef3ae83d6a043391/pages/blog/tags/%5Bslug%5D.vue#L32)

Great! Let's see it in action.

![Demo of a deployed project](https://blog.openreplay.com/7e742f6366f01faf3cf734859c453acc/image12.gif)

Nice!
You can view the [deployed version here](https://content-v2-blog.netlify.app/).

## Conclusion

So far, we've managed to build a blog with Nuxt 3 and the Nuxt Content v2 module. We've created a `/blog` route where all our blog posts and articles live.

Currently, all our documents are in the `./content/` folder. Content displayed in the `/blog` route of our website renders documents from the `./content/blog` folder of our project.

We can also create more pages on our website by creating a new subfolder in the `./content/` folder.
Say we want to have a `/snippets` route where we have short documents about some useful code snippets, we can easily create a new subfolder `./content/snippets/` and create documents in that folder. Then we can do something similar for the `/snippets` route as we did for our page's `/blog` route and create a dynamic page like `./pages/snippets/[slug].vue`. We can use the `<ContentDoc />` component to render content for each document or we use `queryContent()` directly.

This shows how we can easily display content on our site without having to manage an external database or API to manage content.

We can further add more features and functionality to our Nuxt Content powered project by going through the [API documentation](https://content.nuxtjs.org/api/components/content-doc), [guides](https://content.nuxtjs.org/guide/writing/content-directory), [configuration](https://content.nuxtjs.org/api/configuration) and [examples](https://content.nuxtjs.org/examples/essentials/hello-world).

The advanced section in the Nuxt Content documentation is helpful as it shows how hooks allow you to further control and customize the experience.

An example in the advanced section shows [how to use the first picture in the document as a cover image](https://content.nuxtjs.org/api/advanced#example-1).

So far, we've managed to cover the basics, and there's much more you can do with some research. Happy coding!

## Resources & further reading

You can view the code for the project and the live example:

- [Project code on GitHub](https://github.com/miracleonyenma/content-v2-blog)
- [Project deployed on Netlify](https://content-v2-blog.netlify.app/)

As for further reading,

- [The Content guide](https://content.nuxtjs.org/guide/writing/content-directory) is a great way to learn about the module.
- [Multiple examples on using Content](https://content.nuxtjs.org/examples/essentials/hello-world)


[![newsletter](https://blog.openreplay.com/static/e6b18eda7a327511296a2f27692f9c70/e9957/newsletter.png)](https://newsletter.openreplay.com/)