Schema for Nuxt Content v3

by John Pennock 5/19/2026
A sphere labeled `Data Schema` rolling on a roller-coaster like track with various stopping platforms with signs about what good data schema looks like.

Introduction

In my previous blog post about upgrading to Nuxt Content v3, I discussed the new collections system and how to define collections and schemas for your content. In this article, I'll go into more detail about how I implemented a unified data schema for my content using Zod schemas for both the collection and page front matter, and how this has improved the structure and consistency of my content data.

Usually, you don't want to reorganize the content system and introduce data schema when you do an upgrade, but in this case, with the introduction of the Zod schema to the content collection, it made it simpler and presented opportunities to solve the previous problems.

Nuxt Content Collection

The introduction of the collection concept in Nuxt Content v3 allows for multiple collections of content and data. 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()
  3. Nuxt Content v2 to v3 (with Nuxt v4 Upgrade)
  4. Nuxt v3 to v4 Upgrade (with Nuxt Content v3 upgrade)
  5. Nuxt Content v3 Schema and Collections

I've covered the basics of upgrading to collections here.

I further discussed some other Nuxt Content migration issues here

Zod Schemas

The new use of Zod schemas in Nuxt Content v3 allows for structured data validation. This structured data schema was incredibly useful, for example here is a basic defineContentConfig() using a Zod schema.

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

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: 'blog/*.md',
      // Define custom schema for docs collection
      schema: z.object({
        tags: z.array(z.string()),
        image: z.string(),
        date: z.date()
      })
    })
  }
})

This Zod schema brings the power of schema design and typing to both JavaScript and TypeScript modules, allowing for consistent data structures and type safety. My previous system was decidedly unstructured and very JavaScript only.

The Big Picture

My goals in my content system is that I wanted to give the author of each content page the ability to create content as a single source of truth for the page and and meld system defaults to the page data so each page can have unique SEO metadata and Social Sharing appearances, say having a different image or title for Facebook OpenGraph or Twitter Cards, but also to having default SEO metadata for the site as a whole. I also wanted to be able to easily query against the content and its front matter variables to populate custom controls on the page, such as a list of related articles based on shared tags. In short I wanted each markdown content page to be self contained and at the control of the author.

Old vs. New Data System

To illustrate the new data system, I'll illustrate using different types of data and showing how it was done previously and how it is done in the new system with a schema. To show the differences, I'll use these data variables as examples.

Selected Data Variables

  1. robots - default variable for the robots meta tag, e.g. 'index, follow', for the APP (set once and not per page)
  2. rootUrl - default global variable for the root URL of the site, used for constructing full URLs for social sharing and canonical links, e.g. 'https://pennockprojects.com', for use in the PAGE to attach to the page path for full URL construction.
  3. title - Front matter default front matter variable for the title of the markdown page
  4. image - Front matter custom front matter variable for the image of the markdown page
  5. ogImage - Front matter custom front matter variable for the image of the page when shared on Facebook OpenGraph, which can be different from the default image variable for the page when shared on other platforms, e.g. X/Twitter, etc.
  6. dateCreated - Front matter custom front matter variable for the date created of the markdown page

The page author could add an image variable to the front matter of the markdown page, and this would override the default image variable for that page if that page was shared on social media. If the page author didn't include a new image, the default image would be used. etc.

Original System

Here is the pseudocode for the old method

  1. In app.vue define metaDefaults = {} JSON object and provide() it.
  2. In [...slug].vue (and any custom pages)
    • query the front-matter content for the page using queryContent(),
    • import setSEO() function and call it with page content data, metaDefaults, and route path.
    • with the result of the setSEO() function, set the SEO metadata for the page with useSeoMeta() and useHead().

All the objects here are POJOs and untyped, and the front matter variables are unstructured and not defined in any schema, so there is a lot of flexibility but also a lot of potential for errors and inconsistencies.

Original app.vue metadata

In app.vue I defined a metaDefaults POJO object that contained all the default metadata and then I provided this object to all child components using the provide()/inject() function.

// app.vue
<script setup>

const metaDefaults = {
  title: 'Pennock Projects',
  rootUrl: "https://pennockprojects.com",
  robots: 'index, follow',
  image2x1: '/images/PennockProjectsFB.jpg',
}

// allow children components readonly access to social defaults.
provide("metaDefaults", metaDefaults);

// Setting Global SEO on each page
useSeoMeta({
  robots: metaDefaults.robots
  // other global SEO meta tags can go here as well, e.g. copyright
})

// ... rest of app.vue code
</script>

Original [...slug].vue page

In [...slug].vue (and any custom pages) I obtained the defaults with inject("metaDefaults") and then queried the front-matter content for the page using queryContent(), flatten the default front matter with the meta front matter. Then call an imported shared function setSEO(metaPage, metaDefaults, route.path) the page data, default data, and the current route. Finally with the result of the setSEO() function, I set the SEO metadata for the page with useSeoMeta() and useHead().

const route = useRoute()
const metaDefaults = inject("metaDefaults");

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

const doc = page?.value || {}

const schemaFrontMatter = {
  title: doc.title,
  dateCreated: doc.dateCreated
}

// all the other front matter fields are in the `.meta` property of the doc, so we spread those in as well to make it easier to access them in the template and for SEO purposes
const metaPage = { ...schemaFrontMatter, ...doc?.meta}

// Call the blending function
const seoSettings = setSEO(metaPage, metaDefaults, route.path)

// Use the results
useHead(() => (seoSettings.head))
useSeoMeta(seoSettings.seo)

And here is the setSEO() function that blended the page content data with the default metadata and returned the SEO metadata and head settings for the page. Of particular note is how the data is untyped and unstructured.

// /shared/utils/setSEO.js
export const setSEO = (metaPage, metaDefaults, routePath) => {

  let doc = metaPage || {}
  
  let seo = {
    ogTitle: (doc.ogTitle || doc.title),
    ogImage: metaDefaults.rootUrl +  (doc.ogImage || doc.image || metaDefaults.image2x1),
    ogUrl: metaDefaults.rootUrl + routePath,
  }

  let head = {
    link: [
      {
        rel: 'canonical',
        href: metaDefaults.rootUrl + routePath,
      },
    ],
  }

  return {
    head,
    seo 
  }
}

New System

Here is the pseudocode for the new system.

  1. Created a new TypeScript file defaultDataSchema.ts that contains data for app defaults, and the schema for page collections, and page data using Zod.
  2. In app.vue import default data from defaultDataSchema.ts
  3. In content.config.ts import the schema type for collection page defineCollection()
  4. In [...slug].vue - import the app defaults and page data schema type, and then:
    • query the front-matter page variables using queryCollection(), and use the page data schema type to type the result of the query.
    • import setSEO() function and call it with page content data, defaults, and route path.
    • with the result of the setSEO() function, set the SEO metadata for the page with useSeoMeta() and useHead().
  5. In shared setSEO() function, use the defaults and page data types to blend the return data.

new defaultDataSchema.ts

  1. SeoMetaDefaults - TypeScript type for the default SEO metadata for the app.
  2. defaults - The default data based on the SeoMetaDefaults type, which is exported for use throughout the app.
  3. PageSchemaCustom - Zod schema for the custom front matter variables.
    • Note that you should not include the default front matter variables that Nuxt Content provides at the top level, such as title, description, navigation, etc. in this schema, because these are already defined at the top level by Nuxt Content and including them in the custom schema will cause issues with the queryCollection and the front matter variables not being available at the top level.
    • Note that the front matter variable names must be snake_case in NuxtContent v3 (breaking change from v2) as they store the variables in a SQLite database for queryCollection (snake_case is a limitation of SQLite).
  4. PageSchema - Zod schema for the page collection, which adds back the default front matter variables by extending the PageSchemaCustom.
  5. PageMatter - A TypeScript type for the all top page front matter variables (default and custom), inferred from the PageSchema Zod schema.
import { z } from 'zod'

// Define the type for the default SEO metadata
export type SeoMetaDefaults = {
  title: string
  image2x1: string
  robots: string
  rootUrl: string
  // other default SEO metadata fields can go here as well, e.g. copyright, etc.
}

// define the singular default data structure for the app, which can be used in the app and blended with page data for SEO and other purposes. This is the single source of truth for default data for the app.
export const defaults: SeoMetaDefaults = {
  title: 'Pennock Projects',
  image2x1: '/images/PennockProjectsFB.jpg',
  robots: 'index, follow',
  rootUrl: "https://pennockprojects.com",
}


// Define the schema for page frontmatter using Zod
// Note front-matter variable names **must** be snake_case in NuxtContent v3 (breaking change from v2) as they store the variables in a SQLite database for queryCollection (snake_case is a limitation of SQLite).
export const PageSchemaCustom = z
  .object({
    image: z.string().optional().nullable(),
    og_title: z.string().optional().nullable(), // Open Graph title override
    og_image: z.string().optional().nullable(), // Open Graph image override
    date_created: z.string().optional().nullable().default(''),
  })
  
// Extend the base PageSchemaCustom with additional default fields for our page frontmatter
export const PageSchema = PageSchemaCustom.extend({
  title: z.string(),
})

// Infer the Page frontmatter variable object TypeScript type from the Zod schema
export type PageMatter = z.infer<typeof PageSchema>;

new content.config.ts

In content.config.ts I imported the PageSchemaCustom Zod schema type and used it in the schema key of the defineCollection() function for the page collection. This way, I can ensure that all my page content adheres to the defined schema and I can easily query against the front matter variables defined in the schema.

import { defineContentConfig, defineCollection} from '@nuxt/content'
import { PageSchemaCustom } from './shared/utils/defaultDataSchema'


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

new [...slug].vue page

In [...slug].vue (and any custom pages) I imported the PageMatter types from defaultDataSchema.ts and then queried the front-matter page variables using queryCollection(), and used the PageMatter type to type the result of the query. Then I called an imported shared function setSEO(page?.value || {}, route.path) Finally with the result of the setSEO() function, I set the SEO metadata for the page with useSeoMeta() and useHead(). The data is now typed and structured based on the Zod schema.

<script setup lang="ts">
import { setSEO } from '~/shared/utils/setSEO';
import type { PageMatter } from '~/shared/utils/defaultDataSchema';

const route = useRoute()

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

const seoSettings = setSEO(page?.value || {}, route.path)

useHead(seoSettings.headData)
useSeoMeta(seoSettings.seoMetaData)
</script>

Note, I also use lang="ts" in the script tag to enable TypeScript for this Vue component.

new setSEO() function

In the shared setSEO() function, I used the PageSchema schema and PageMatter type. I also imported the default data. The function blended the page content data with the default metadata and returned the SEO metadata and head settings for the page. The data is now typed and structured based on the Zod schema.

import type { ContentCollectionItem } from '@nuxt/content'
import type { UseHeadInput, UseSeoMetaInput } from '@unhead/vue'
import { PageSchema, type PageMatter } from '~/shared/utils/defaultDataSchema'
import { defaults } from '~/shared/utils/defaultDataSchema'

type UseHeadAndSeoInput = {
  headData: UseHeadInput
  seoMetaData: UseSeoMetaInput
  pageData: PageMatter
}

export const setSEO = (
  pageRaw: ContentCollectionItem | Record<string, any>, // Accept either a ContentCollectionItem or a plain object for page front matter data
  routePath: string
): UseHeadAndSeoInput => {

  let pageData: PageMatter  = {} as PageMatter;

  try {
    pageData = PageSchema.parse(pageRaw);

  } catch (error) {
    console.error('Error parsing page front matter with schema:', error);
    pageData = pageData || {} as PageMatter; // Ensure pageData is at least an empty object if parsing fails
  }

  const ogTitle = pageData.og_title || pageData.title  
  const finalOgTitle = ogTitle && ogTitle !== defaults.title
    ? `${defaults.title} ${ogTitle}`
    : defaults.title
  const ogImage = pageData.og_image ?? pageData.image ?? defaults.image2x1
  const ogUrl = defaults.rootUrl + (pageData.path ?? routePath)

  const headData: UseHeadInput = {
    link: [
      {
        rel: 'canonical',
        href: defaults.rootUrl + routePath,
      },
    ],
  }

  const seoMetaData: UseSeoMetaInput = {
    ogTitle: finalOgTitle,
    ogImage: defaults.rootUrl + ogImage,
    ogUrl,
  }

  return {
    headData,
    seoMetaData,
    pageData,
  }
}

Note also that I added error handling for the Zod schema parsing, so if there is an issue with the page front matter data not adhering to the schema, it will log an error and continue with an empty page data object.

New System Benefits

The new system with Zod schemas provides a more structured and typed approach to handling content data and SEO metadata. The key data objects of the content are defined in one place and then used by the various parts of the system. It allows for better validation of content front matter, easier querying against defined variables, and a more maintainable codebase with clear data structures. The use of TypeScript types also enhances the developer experience by providing type safety and autocompletion in the code editor.