NuxtContent v2 to v3 Upgrade on Nuxt v3

by John Pennock 2/17/2025
NuxtContent v3 Logo

Newer Info

I've recently upgraded my blog site to Nuxt v4 and Nuxt Content v3, and I have written about the issues I encountered and how I resolved them in the following blog posts:

  1. Nuxt Content v2 to v3 (on Nuxt v4)
  2. Nuxt v3 to v4 Upgrade
  3. Nuxt Content v3 Schema and Collections

Original Post from 2025

When moving my blog files over to the new JAMStart repo and running the npm install it warned of errors in Nuxt Content 2. I upgraded to Nuxt Content 3 with npm audit fix --force and here are the issues I encountered and here is how I resolved them.

Migration and Installation

Nuxt Content v3 migration documentation can be found at:

  1. installation guide
  2. migration

content.config.ts file

Defining Collections

The first concern was related to the breaking change introduced with Nuxt 3, i.e. collections. See Nuxt Content Collection Definition

WARN No content configuration found, falling back to default collection. In order to have full control over your collections, create the config file in project root.

Instead of defaulting to a single assumed collection of content stored in /content folder, you now define multiple collections and for each specify the root directory, files, and schemas in a new configuration file content.config.ts with the use of two new functions:

  1. defineContentConfig()
  2. defineCollection()

The basic structure of the content.config.ts looks like this (although if you use @nuxtjs/sitemap it is different, see Sitemap content.config.ts)

import { defineContentConfig, defineCollection } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md'
    })
  }
})

It took me a while to figure out is that the key name is the directory name for the collection. For example, in the basic configuration above, the key content said this collection root folder will be /content. If you want to put your content files for your collection in a different directory, for example in the /static directory, you would change the configuration like this.

export default defineContentConfig({
  collections: {
    static: defineCollection({
      type: 'page',
      source: '**/*.md'
    })
  }
})

or two collections at the same time

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md'
    }),
    static: defineCollection({
      type: 'page',
      source: '**/*.md'
    })
  }
})

Collection Schema

In the collection definition, you can also define a schema for the collection. The types are defined by the zod library. In order to avoid zod conflicts, you should install zod via npm install zod. This is where you define the front-matter variables that you want to be able to query against with queryCollection methods such as .where(), .select(), etc. For example, if you have a date_created and topic front-matter variable you want to be able to query against, you would add it to the schema like this:

import { defineContentConfig, defineCollection} from '@nuxt/content'
import { z } from 'zod'

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md',
      schema: z.object({
        date_created: z.date(),
        topic: z.string().optional()
      })
    })
  },
})

Overlapping Collections

Note, as of v3.6.0 there is an issue with overlapping collection. It is noted in the documentation with

Currently, a document is designed to be present in only one collection at a time. If a file is referenced in multiple collections, live reload will not work correctly. To avoid this, it is recommended to use the exclude attribute to explicitly exclude a document from other collections using appropriate regex patterns.This topic is still under discussion in this issue: nuxt/content#2966.

I initially thought I could define a global content collection for /content directory and second blog collection mapped to /content/blog directory, but this failed as the parent /content directory contained the /content/blog directory. Instead I achieved my same goal by having a global content collection and used a .where() filter on the query to just get content in the /content/blog directory.

const query = queryCollection('content')
    .where('path',  'LIKE', '/blog/%')
    .select('title', 'path', 'description', 'topic', 'date_created')

Collection Queries

queryCollection() replaces queryContent(). The biggest change is that you have to specify the collection name in the query. For example, if you have a collection called content you would query it like this:

// Content v2
const v2Query = await queryContent(route.path).findOne()

// Content v3 - don't forget to create `content` collection in `content.config.ts`
const v3Query = await queryCollection('content').path(route.path).first()

of within a page component, you can use useAsyncData to make the query reactive to route changes, like with the [...slug].vue page component.

// Content v2
const { data:page } = await useAsyncData(route.path, () => {
  return queryContent(route.path).findOne()
});

// Content v3
const { data: page } = await useAsyncData(route.path, () => {
  return queryCollection('content').path(route.path).first()
});

Note that use of the path() filter is optional, but a direct replacement to the first parameter of queryContent(). It is a convenient way to query for a document based on the path. You can also use other filters such as where(), sort(), etc. See Nuxt Content Query Utils.

Example Changes

// Content v2
- const query = queryContent(props.subCollection)
-  .where({_path: { $ne: '/'+props.subCollection }})
-  .where({ format: props.format})
-  .sort(props.sort)
-
// Content v3
+ const query = queryCollection('content')
+   .where('path', '<>', '/'+props.subCollection)
+   .where('format', '=', props.format)
+   .sort(props.sort)

Note you can get errors such as

ERROR  [request error] [unhandled] [POST] http://localhost/__nuxt_content/content/query?v=v3.5.0--EVUz4CgByv8PG3D_0WYjP-lsGKcv32Z5s--mnsxjiqQ
 Invalid query: Query must be a valid SELECT statement with proper syntax

if you don't use correct uppercase the operators in the .order() method, i.e. ASC or DESC instead of asc or desc. This is a change from Nuxt Content v2 where you could use either uppercase or lowercase for the sort order.

FrontMatter Changes

Schema and Meta

I was using front matter variables to control how a page might behave. The variables manifest differently from NuxtContent v2 than in NuxtContent v3. Specifically, only the following front-matter variables are native by default:

  1. title
  2. description
  3. navigation

meaning they can all be accessed at the top level of the document, e.g. doc.title, doc.description, doc.navigation.

All other front-matter variables that are not default (or are not defined in the schema of the collection) are not available at the top level of the document, but instead are nested within a meta key. For example, if you have a topic front-matter variable you can access it with doc.meta.topic. However these non-default meta tags are not available for querying directly again with queryCollection using the .select() and .where() methods. This is a significant change from NuxtContent v2 where all front-matter variables were available at the top level of the document.

To make the non-default front-matter variables available at the top level of the document, you have to explicitly define them in the schema of the collection definition in content.config.ts. For example, if you have a date_created and topic front-matter variable you want to be able to query against, you would add it to the schema like this:

export default defineContentConfig({
  collections: {
    content: defineCollection({
      source: '**',
      type: 'page',
      schema: z.object({
        date_created: z.date(),
        topic: z.string().optional()
      })
    })
  },
})

Then you can easily query against date_created variable, and so forth. For example, here find all content collection document title where the topic is NuxtContent and the date_created is before 2024-04-04:

const query = queryCollection('content')
    .select('title','topic', 'date_created')
    .where('topic', '=', 'NuxtContent')
    .andWhere('date_created', '<', '2024-04-04')

(The rule of thumb is that you want to define any front-matter variable in the schema that you want to be able to query against with queryCollection methods such as .where(), .select(), etc. If you don't define it in the schema, it will still be available at the top level of the document, but you won't be able to query against it with queryCollection methods.){.important}

Front-Matter Variable Names

One breaking change in NuxtContent v3 is that it uses SQL lite as a database to store the front-matter variables. This creates challenges with variable naming conventions. Specifically, if you have previously defined a camelCase front-matter variable such as createDate in NuxtContent v2, you will need to change it to snake_case create_date in NuxtContent v3. This is because SQL lite does not support camelCase variable names. If you try to use camelCase variable names in NuxtContent v3, you will get an error when you try to query against it with queryCollection methods such as .where(), .select(), etc. For example, if you have a createDate front-matter variable and you try to query against it with queryCollection('content').where('createDate', '=', '2024-04-04') you will get an error because createDate is not a valid variable name in SQL lite. To fix this, you would need to change the front-matter variable name to create_date and then query against it with queryCollection('content').where('create_date', '=', '2024-04-04'). This is a significant breaking change from NuxtContent v2 where you could use camelCase variable names without any issues.

Front-Matter Strings

String nesting doesn't work. For example, if you have a front-matter variable called format and you define it like this format: "'Article' 'Post'" in NuxtContent v2, it will be stored as a string with the value "'Article' 'Post'" in NuxtContent v3. This is because NuxtContent v3 does not support string nesting in front-matter variables. If you try to use string nesting in NuxtContent v3, you will get an error when you try to query against it with queryCollection methods such as .where(), .select(), etc. For example, if you have a format front-matter variable defined with string nesting and you try to query against it with queryCollection('content').where('format', '=', "'Article' 'Post'") you will get an error because the value of format is not a valid string in SQL lite. To fix this, you would need to change the front-matter variable definition to format: "Article Post" and then query against it with queryCollection('content').where('format', '=', 'Article Post'). This is a significant breaking change from NuxtContent v2 where you could use string nesting in front-matter variables without any issues.


format: "article" # "'Hub' 'Code' 'Cheat Sheet' 'How-to' 'Blog' 'Article' 'Newsletter' 'Troubleshooting'"



#### SEO and Social Metadata




## `#content/server` error

An unexpected error I encountered was: 

`$ Could not resolve import "#content/server" in ...sitemap.xml.js using imports defined in ...package.json.`

In reviewing the NuxtContent [documentation](https://content.nuxt.com/docs/getting-started/migration){target=_blank} and investigation, this was an issue with the `server/routes/sitemap.xml.js` file. This file was added to support the `sitemap` [NPM module site](https://www.npmjs.com/package/sitemap){target=_blank} It's contents looked like this:

```js
import { serverQueryContent } from '#content/server'
import { SitemapStream, streamToPromise } from 'sitemap'

export default defineEventHandler(async (event) => {
  // Fetch all documents
  const docs = await serverQueryContent(event).find()
  const sitemap = new SitemapStream({
    hostname: 'http://localhost:3001'
  })

  for (const doc of docs) {
    sitemap.write({
      url: doc._path,
      changefreq: 'monthly'
    })
  }
  sitemap.end()

  return streamToPromise(sitemap)
})

Sitemap

Instead of sitemap, NuxtContent recommends @nuxtjs/sitemap for sitemap.xml.

Steps to Replace

  1. Uninstall "sitemap" - npm uninstall sitemap
  2. Remove the server/routes/sitemap.xml.js file, i.e. git rm server/routes/sitemap.xml.js
  3. Installed NuxtSEO "sitemap" - npm install @nuxtjs/sitemap
  4. Create a new file called content.config.ts in the root of the project and add the following contents:
import { defineContentConfig, defineCollection } from '@nuxt/content'
import { asSitemapCollection } from '@nuxtjs/sitemap/content'

export default defineContentConfig({
  collections: {
    content: defineCollection(
      asSitemapCollection({
        type: 'page',
        source: '**/*.md',
        schema: z.object({
          date_created: z.date()
        })
      })
    )
  }
})
  1. Also upgrade your nuxt.config.js file
  • Due to current Nuxt Content v3 limitations, you must load the sitemap module before the content module.
  • Add a Nitro prerender key
export default defineNuxtConfig({
  modules: [
    // ...
    '@nuxtjs/sitemap',
    '@nuxt/content' // <-- Must be after @nuxtjs/sitemap
    // ...
  ],
  
  // ...
  nitro: {
    prerender: {
      autoSubfolderIndex: false,
      crawlLinks: true,
      routes: ['/sitemap.xml', '/']
    }
  }
  // ...
})
  1. Finally, make sure your nuxt and @nuxt/content modules are updated (without breaking changes) (I received an error until I made sure both of those and @nuxtjs/sitemap were up to date.)
  • npm update nuxt @nuxt/content