Security Research & Web Development

Migrating from Gridsome to Nuxt 3

Building a blog with the Nuxt Content module

Published on

It's official, this site is now running on Nuxt 3!

My last big site update to switch into the Vue ecosystem with the Gridsome framework was a few years ago. Overall Gridsome has been a great framework to use for building a static site using Vue 2, and a Vue 3 upgrade was on its roadmap, but like many projects the updates haven't continued—coincidentally they stopped shortly after I transitioned to it in November 2020. With everyone still deep in the pandemic it wasn't exactly top of mind to worry about a little Javascript framework receiving updates regularly. Fast forward about a year though and Nuxt 3 announced their beta. With no updates forthcoming to Gridsome I kept it in the back of my mind to test Nuxt out. It was testing out the Content module that convinced me I should make the switch, as it was going to solve a few of the pain points that Gridsome has with using Vue components within markdown.

Nuxt 3 had its first stable release in late 2022 so with that it was time to make the transition. On paper this switch shouldn't be a complex migration, considering both of these are VueJS frameworks, but it is a jump from Vue 2 to Vue 3, with some breaking changes to consider. For me though the main point of migrating, beyond keeping the site updated, is to continue learning and this project meant I had the excuse to get more practice with Vue 3, Typescript, Tailwind, and newer build tooling like Vite.

What you're seeing now is a relatively quick MVP of the Nuxt version of this site. My original intention was to tear out all the old CSS and do a refactor with Tailwind, but as I consider this design to be a temporary one while I think up a stronger visual identity it made more sense to see how well the Gridsome build would transition over without major surgery.

First impressions of Nuxt 3

There are a handful of changes in Vue 3 and in the way Nuxt is configured that make for an easier dev experience compared to Gridsome. The biggest change is Vue's composition API, which is used throughout Nuxt and especially in the Content module that powers all the markdown rendering on this site. Compared to Vue 2's mixture of mixins, filters, and plugins it is a more consistent system. Running Vue 3 under the hood also removes the need for little things like using this.someProp in computed functions. It also helps with better code organization in single-file components plus native Typescript support.

That's all Vue 3, now for Nuxt specifically the largest improvements have been in overall developer experience: auto-importing components, intuitive directory-based routing, and a lot of excellent modules to add functionality to the site—for this site I'm currently using Content, VueUse, a native Nuxt Tailwind module, and Google fonts integration. There are many many others, some of which I'm sure I'll test out as I continue to improve the site like color-mode for easy light/dark switches, Image for well...image optimization, Pinia for state management, an security module, etc.

Plus the Content module specifically has a few nice features from nice to have to excellent: a built-in table of contents generator for long posts like this one (you still need to make a component but the linking part is handled, more on that later); markdown components (MDC) so that it's easier to mix vanilla markdown with Vue components for handling functionality like shortcodes; a query API that uses a MongoDB like document syntax but is otherwise normal Javascript—I liked Gridsome's GraphQL integration overall, but it did add another layer of complexity to the data handling process.

I've still got a lot to explore with Nuxt 3 so I'll be sure to update on particular bits I've found later. For now let's move on to specific details of the build and some of the challenges I had to solve to get it running.

The Migration & Build

For this project I decided my approach would be to start by installing Nuxt in a fresh repository, getting a baseline version that renders out without errors, then introducing my old components from Gridsome one-by-one with, fixing the obvious syntax differences, and then tweaking them until the errors cleared.

To get the fresh install going the Nuxt docs are well written and it was simple to start a project. In my case using NPX with npx create-nuxt-app <my-project>. This create-nuxt-app tool has a lot of handy defaults that you can select, including CSS frameworks, linting, Typescript useage, test frameworks, etc. In my case I kept this fairly light: Tailwind, Nuxt Content, ESLint, and Universal rendering selected (which allows for both SSR and static generation).

Once that runs we have a new directory that starts us off with a minimalist project with only a few files: a main app.vue entry point, nuxt.config.ts, and tsconfig.json files. From there to quickly mockup our directory structure and fill in a few essential pieces we can create the root /components, /pages, /assets, and /static directories. Next to quickly create boilerplate pages and components we can use the Nuxt's CLI, Nuxi, by running nuxi add component componentName to scaffold our components (this supports all the major types) and nuxi add page pageName for initial pages.

As a quick aside: our create-nuxt-app method handled getting our module dependencies installed for us. To add additional ones, or to make modifications to their configuration, you have to edit nuxt.config.ts. With Gridsome there was a main.js file in the root that handled CSS imports and other Javascript dependencies, but with Nuxt those are also all handled in the config file. It is also responsible for site-wide head metadata and some of the Nitro server's behavior (this will come up later when setting up sitemaps and RSS).

As we're using the Content module for handling or copy and media rendering the other piece we need to setup is creating a /content directory. Nuxt Content will parse any .md or .yaml files in that directory, generating routes for each based on its path. But to actually render those views we need to cover two areas: our layout and the individual page views.

The simplest way to handle site layout, as recommended in the Getting Started docs, is to create an app.vue file in the root directory which includes the NuxtPage component, which itself is acting as a wrapper around Vue Router's RouterView component. The baseline component will look like this:

<template>
<div>
<NuxtPage />
</div>
</template>

But in my case I want to future proof my design a little and allow for multiple possible layouts, so I also created a component in the layouts directory, /layouts/default.vue, where I handle loading in all the child components necessary to the basic layout. Now to make this work we need to also wrap our NuxtPage component in app.vue with <NuxtLayout></NuxtLayout>. Lastly we need to include a <slot /> in our new layout file where we want our content to be loaded.

With that the layout set I have two major content types that need to be rendered from Markdown: general pages and blog posts. Nuxt makes this easy as it supports dynamic routing with a bracket syntax to denote the variable [slug].vue will generate a route per unique slug, and it supports other route parameters and custom parameters as well. Nuxt also supports using the spread operator (...) with our parameters to create a catch all dynamic routes like [...slug].vue. With that catch all route component you could render out *all of your markdown content with just this in your component:

<template>
<main>
<ContentDoc />
</main>
</template>

Under the hood ContentDoc automatically accesses the current route parameters to source the correct data and render it out to the page. Even though this [...slug].vue template lives in the root, it will also automatically create routes for pages nested further into the folder hierarchy defined in /content. If that's all you need then great!

If you want to have more precise control over certain page templates you can go further, as I did, and define additional route specific components. I added support for specific blog components in order to add functions like pagination and tags that aren't needed by other pages. To create those dynamic route I created a subdirectory of /pages/blog for our routing, with a dynamic route of /pages/blog/[slug].vue, and an index page to catch anyone going to /blog that is /blog/index.vue. Nuxt's behavior is similarly to CSS here, where if there are multiple matching components it could use to manage dynamic routes, it will favor the most specific one, only choosing a more generic option (like our root [...slug].vue) if it doesn't find any others first. With this done we have all our routes rendering out for any page that was created within /content!

Displaying metadata

The catch though with the ContentDoc method above and is that it only takes in the body of a markdown document. My markdown docs also contain metadata inside of a YAML formatted header. Nuxt already reads this data correctly, but it doesn't include it in the rendering using the above method. In working on this I discovered at least two ways to address this issue. The documented method is to use ContentDoc with a v-slot attribute: <ContentDoc v-slot"{ doc }">, which gives us an object to work with, which is then passed on to a nested ContentRenderer component with <ContentRenderer :value="doc">. We can then nest HTML inside to access our individual metadata items with the standard Vue double mustache syntax <h1>{{ doc.title }}</h1>. For rendering the body of our markdown content the last step is to nest the one last component: <ContentRendererMarkdown :value="doc" />, with our value attribute taking the whole object loaded in from ContentDoc earlier. A Full example below.

<template>
<main>
<ContentDoc v-slot="{ doc }">
<h1>{{ doc.title }}</h1>
<time>{{ doc.date }}</time>
<ContentRenderer :value="doc" />
</ContentDoc>
</main>
</template>

In my early testing of my build this approach worked well, and its still in use for my catch-all pages route. But while I was researching the best way to add pagination functions to Nuxt I found an excellent Nuxt 3 blog written by Debbie O'Brien. While looking in her site's Github repo I saw that she implemented the above metadata and routing functions differently, loading in the our markdown page's object within the scripts section of the component, rather than in our template, leveraging the useAsyncData method to access one of Nuxt Content's main composables: queryContent(). This approach has the advantage of allowing us manipulate and format the data within our Javascript first, before passing it off to the template. In some more specific use cases this would also allow you to take that data query and destructure the object to only bring in what you need in this specific component. This method will also come in handy for more complex queries, as it accesses the MongoDB like query API that Nuxt Content is using.

With the above approach the /blog/[slug].vue component ends up looking something like this:

const { data: blogPost } = await useAsyncData(path.replace(/\/$/, ''),
() => queryContent<BlogPost>('blog')
.where({ _path: path })
.findOne(),
)
const title: string = blogPost.value?.title || ''
const date: Date = blogPost.value?.date || ''
// ...
<template>
<article v-if="blogPost">
<header class="mb-8">
<h1 class="mb-0">{{ title }}</h1>
<div class="blog-date mt-1">
<span>Published on <time>{{ useDateFormat(date, 'D MMMM, YYYY').value }}</time></span>
</div>
</header>
<ContentRenderer :value="blogPost" class="shiki">
<template #empty class="shiki">
<p>No content found.</p>
</template>
</ContentRenderer>
</article>
</template>

A few small notes on this version:

  1. having the <template #empty> segment is necessary here otherwise the page will render out with no body content.
  2. the useDateFormat you see for our date is a composable from the super handy VueUse module. I had initially went into Vue 2 mode and wanted to create a filter, but that's not the Vue 3 way. Using a composable is how a global utility function should be implemented in Vue 3.

At this point just using the above components plus your general layout components for handling the header, footer, navigation, etc. are enough to have a functional site for reading the pages and posts. However, as this is a blog I'd be remiss not to add some basic RSS and sitemap functions to it as well.

Nuxt Servers: RSS & Sitemaps

Discussing Nuxt servers in-depth is beyond the scope of what I'm trying to do here, if you're curious read the docs as a starting point. For me right now the goal of using server functions is to create XML routes that add sitemap and RSS functionality to the site. With Nuxt server we need to place our scripts in the /server/routes folder for each of them to have a route directly off the root domain.

For our sitemap, we can make use of the documentation on this function from Nuxt Content as this works well enough as described. Create a sitemap.xml.ts file in our server routes directory and you can copy the code in the docs, set the nitro server config as specified, and update the hostname URL value to have a working sitemap on the route /sitemap.xml.

For RSS, our server setup works differently as I want to limit the query to just the blog posts. In the future I may try and break this out into categories, but for now the implementation written up here worked as expected. Just be sure to install the rss library from NPM as a dev dependency, which wasn't mentioned explicitly in that guide. With that complete our RSS feed is accessible at /rss.xml.

Leveraging the table of contents feature

Writing lengthier posts like this one brings us to one of the last features I wanted to have working on version 1 of this site: table of content lists. Nuxt Content comes with a built-in table of content generator on any markdown file that it parses and renders out. Out of the box this feature will walk through your markdown content looking for headers (header depth is defined in nuxt.config.ts, see the docs). For the headers it finds it will automatically add an id attribute to the header tag, matching the header text, along with a nested anchor link in the format of #your-route-slug-here.

The small downside to this is if you don't touch it otherwise, it'll make all your headers into links, visually, so I setup a quick CSS adjustment to strip them of the default link behavior:

a[href^="#"] {
pointer-events: none;
text-decoration: none;
}

However, that's where the plain install of Nuxt Content stops with the TOC feature. To make it use of those anchor links we need to create a component. Debbie's Nuxt repo from earlier already has a TOC component, so for simplicity I implemented that component, with some stylistic adjustments, and it immediately worked as intended. The one tweak I've made is to have the TOC as an optional component, rather than including it by default in every blog post. This was accomplished with a simple metadata property in the markdown header, hasToc: true, and a v-if statement on the div wrapping our component v-if="hasToc". I considered trying to make this work using an MDC (markdown component) within the markdown file, as an excuse to figure out how Nuxt handles MDC, but based on the docs MDC doesn't support passing in objects as props, so it wouldn't work in this case...and in hindsight would have been a pain to reposition.

Future plans

As a starting point for this site I'm pretty happy with how it's running now. However this was always intended as a transitional phase, as my long-term goal is to shift this site into a monorepo structure that I think Nuxt, with its auto imports and heavy use of composables, is well-suited for. That should help with my overall goal of having a more DRY development process for my own internal projects, so that I can easily share work across without needing to manage multiple repositories.

Beyond that big picture structural shift, I'm expecting to tinker around the edges of this current version. Implementing some necessary features (full pagination, search, categories and tags, etc.) plus nice to have tweaks to the design that include animations, improvements to navigation, and other miscellaneous elements. Concurrently with those improvements I'll be thinking up a stronger visual identity to this place. I had originally built this site as a sort of base template test, with good typographic defaults, to build on top of so the design is rather bare bones. But the real major priority now that the migration is done is to start putting out more writing, so the design updates will come in time.