NuxtContent v2 to v3 Migration

Introduction
Nuxt Content is critical for my static site projects. It is the content management system (CMS) that powers my blogs and project pages. In this case I had to upgrade my blog site Pennock Projects Nuxt Content to v3 and Nuxt framework to v4 at the same time. It was necessary to take advantage of the new features, dependencies, security improvements, and other enhancements. If you are interested in the Nuxt v4 upgrade issues, I have a separate blog post about that here. In this article, I'll focus on my experience with upgrading Nuxt Content, the challenges I faced, and how I resolved them.
Issues discussed in this article include:
- Upgrading Nuxt Content configuration in
nuxt.config.ts - Querying content with
queryCollection()and the new collections system - Frontmatter variables and snake_case requirement
- Zod schemas for defining collections
- Using
queryCollection()to query path in a similar way with v2queryContent() - Naming
useAsyncAwaitcalls withqueryCollection()to avoid hydration errors
Related blog posts about Nuxt Content v3 and Nuxt v4 upgrades:
1. Configuration
The first issue I encountered was upgrading the Nuxt Content configuration in the content key of the nuxt.config.ts file. It took a while to figure out how to migrate them. I initially thought that they would just move over to content.config.ts, but it actually just changed keys in nuxt.config.ts.
Here is the original content configuration in nuxt.config.ts for Nuxt Content v2:
export default defineNuxtConfig({
// main Nuxt Content for v2 configuration in nuxt.config.ts
content: {
markdown: {
anchorLinks: false,
remarkPlugins: ['remark-unwrap-images']
},
highlight: {
theme: {
default: 'min-light',
dark: 'min-dark',
},
langs: [
'json', 'js', 'typescript', 'ts', 'html', 'css', 'vue', 'shell', 'mdc', 'markdown', 'yaml',
'asm', 'c', 'cpp', 'python', 'reg', 'terraform']
}
},
// other Nuxt configuration...
})
You can see the main configuration features I needed was:
- 'remark-unwrap-images' plugin for markdown to unwrap images from paragraphs
- Custom languages for syntax highlighting in code blocks in markdown files
- Dark Mode and Color Themes
- Remove anchor links from markdown headers
Here is the new content configuration in nuxt.config.ts for Nuxt Content v3:
export default defineNuxtConfig({
content: {
build: {
markdown: {
highlight: {
theme: {
default: 'min-light',
dark: 'min-dark',
},
langs: [
'asm', 'c', 'cpp', 'python', 'reg', 'terraform', 'diff'
]
},
remarkPlugins: {
'remark-unwrap-images': {} // No options needed for this plugin
},
toc: {
depth: 2
},
}
},
renderer: {
anchorLinks: false
}
},
// other Nuxt configuration...
})
The main changes in the configuration were:
- The markdown configuration is now nested under a
buildkey, which is a bit confusing. I think it is because it is part of the static site generation process? I'm not actually sure about this, as it is not actually related to the build process as I understand it. - The
remark-unwrap-imagesplugin is now configured as a key name in theremark-pluginsobject, you add an empty object as the value. (Note remark-plugins seem to be discontinued in favor of rehype-plugins, but this one is still supported and works fine for unwrapping images from paragraphs in markdown files and I already had enough upgrades to worry about). - The
anchorLinkskey is now nested under a newrendererkey instead of thebuildkey like everything else. - The
langskey is really only for additional languages beyond the defaults, so I removed the default languages['json', 'js', 'ts', 'html', 'css', 'vue', 'shell', 'mdc', 'md', 'yaml']from thelangsarray and just left the custom languages I added for syntax highlighting in code blocks in markdown files. - Added a new
tocdepth key to configure the table of contents generation for markdown files, I set the depth to 2 to generate a table of contents for headers up to h3. Without this configuration all headers, specifically h4, were included in the table of contents, which was too much for my liking.
2. Frontmatter Queries
The second issue I encountered in querying my content data with queryCollection() was related to the additional metadata in the frontmatter variables. In Nuxt Content v2, you could define any frontmatter variable you wanted and it would be available at the top level of your content queries. In Nuxt Content v3, only a few frontmatter variables are available at the top level, i.e. 'title', 'description', and 'navigation'. For example, the description and navigation variables are available at the top level, but other variables like date_created and date_modified are not available at the top level and are instead nested within a meta key, which is not queryable.
To resolve this issue, you have to explicitly include your custom frontmatter variables in the schema of your collection definition in your content.config.ts file. For example, to include a date_created date variable you have to add it to the schema key in the defineCollection in your content.config.ts file. Then you can easily query against date_created variable, and so forth. I've covered this issue in more detail here.
3. Frontmatter snake_case
The third issue I encountered was that all queryable frontmatter variables had to be defined in snake_case (breaking change from v2) as they store the variables in a SQLite database for queryCollection (snake_case is a limitation of SQLite). So any variables I had previously defined in camelCase or PascalCase had to be changed to snake_case in all of my markdown files and in my code when querying the variables. For example, dateCreated had to be changed to date_created in all of my markdown files and in all of my code where I queried the dateCreated variable. This was a bit of a pain, but it was necessary to make the variables queryable with queryCollection().
4. Zod Schemas
The fourth issue/opportunity was the inclusion of Zod Schemas in the defineContentConfig() and with the renaming of variables to snake_case, it afforded an opportunity for a new system of data defining, typing and shaping, I discuss this in more detail in my blog post about data schema for Nuxt Content v3 here.
5. queryCollection() vs queryContent() paths
The fifth issue I encountered was that queryCollection() is that you can't query against paths like with queryContent(). My previous queries would use a file path to select all content files in subdirectories. With queryCollection() you query collections (which due to problems with nesting collections) meant I had to convert to using a .where() clause to select subdirectories from the path key for each collection item returned. Note, you can't use .path() clause for this purpose is that doesn't include subdirectories. For example, to select all content files in the blog subdirectory with queryContent() I could do:
const blogPosts = await queryContent('blog').find()
With queryCollection() I had to do:
const blogPosts = await queryCollection('content').where('path', 'LIKE', '/blog%').all()
The where('path', 'LIKE', '/blog%') clause is a bit more cumbersome and tricky. The end % is a wildcard to select all content where the path starts with /blog. The three parameters and what operators are available is not completely documented. It's not helpful when it says 'Possible' values include. How about a complete list? I had to research SQL Lite operators available and included with NuxtContent. With trial and error to figure out how to select content files in subdirectories with queryCollection(). This is a bit of a pain and a breaking change from queryContent(), but it is also a bit more powerful and flexible.
One more note is a breaking change is that the previous _path key is now just path in the collection items returned from queryCollection(). This is a bit of a minor change, but it is worth noting.
One final note, is that the .select() method for selecting specific keys from the collection items returned from queryCollection() does not work with the path key, which is a bit of a pain as it means you have to select all keys and then use the where() clause to filter by path, you can't just select the keys you want and filter by path. This is a bit of a limitation of queryCollection() and the collections system in general, as it is more rigid and structured than queryContent()
6. useAsyncData Unique Query Names
The sixth issue I encountered was that when using queryCollection() in my components to fetch content data, I had to wrap the queryCollection() call in a useAsyncData() call to avoid hydration errors. Further, the first parameter of useAsyncData() is a unique key for the query, which helps Nuxt manage the data fetching and caching. If you query data in a content component like BlogList.vue, you can have that name be the same, especially if you are query for different data sets. If, for example, you use the <BlogList> component on different pages, or the component twice on the same page but with different parameters, and you use the same query name in useAsyncData(), you can run into issues with data being cached and shared across different instances of the component, which can lead to unexpected results. To avoid this issue, you can use a unique query name for each instance of the component, which might be based off a parameter rather than just the page.route or you can use a dynamic query name that includes some unique identifier, such as the page slug or a timestamp.
For example, instead of using a static query name like this:
const { data: blogPosts } = useAsyncData('blogPosts', () => queryCollection('content').where('path', 'LIKE', '/blog%').all())
You could use a dynamic query name like this:
const { data: blogPosts } = useAsyncData(`blogPosts-${prop.folder}`, () => queryCollection('content').where('path', 'LIKE', `/${prop.folder}%`).all())
This way, each instance of the component will have a unique query name based on the prop.folder value, which allows Nuxt to manage the data fetching and caching correctly for each instance of the component. This is especially important if you are using the same component on different pages or multiple times on the same page with different parameters, as it ensures that each instance of the component fetches and displays the correct data without interference from other instances.
Conclusion
Upgrading to Nuxt v4 and Nuxt Content v3 was a significant undertaking, but it was necessary to take advantage of the new features and improvements. The upgrade process involved several challenges, including changes to the configuration, querying content with queryCollection(), frontmatter variable handling, and the need to use useAsyncData() for content queries. However, with careful attention to the documentation and some trial and error, I was able to successfully upgrade my projects and take advantage of the new capabilities offered by Nuxt v4 and Nuxt Content v3.