Schema for Nuxt Content v3

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:
defineContentConfig()defineCollection()- Nuxt Content v2 to v3 (with Nuxt v4 Upgrade)
- Nuxt v3 to v4 Upgrade (with Nuxt Content v3 upgrade)
- 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
robots- default variable for the robots meta tag, e.g. 'index, follow', for the APP (set once and not per page)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.title- Front matter default front matter variable for the title of the markdown pageimage- Front matter custom front matter variable for the image of the markdown pageogImage- Front matter custom front matter variable for the image of the page when shared on Facebook OpenGraph, which can be different from the defaultimagevariable for the page when shared on other platforms, e.g. X/Twitter, etc.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
- In
app.vuedefinemetaDefaults = {}JSON object andprovide()it. - 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 withuseSeoMeta()anduseHead().
- query the front-matter content for the page using
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.
- Created a new TypeScript file
defaultDataSchema.tsthat contains data for app defaults, and the schema for page collections, and page data using Zod. - In
app.vueimport default data fromdefaultDataSchema.ts - In
content.config.tsimport the schema type for collection pagedefineCollection() - 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 withuseSeoMeta()anduseHead().
- query the front-matter page variables using
- In shared
setSEO()function, use the defaults and page data types to blend the return data.
new defaultDataSchema.ts
SeoMetaDefaults- TypeScript type for the default SEO metadata for the app.defaults- The default data based on theSeoMetaDefaultstype, which is exported for use throughout the app.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).
- Note that you should not include the default front matter variables that Nuxt Content provides at the top level, such as
PageSchema- Zod schema for the page collection, which adds back the default front matter variables by extending thePageSchemaCustom.PageMatter- A TypeScript type for the all top page front matter variables (default and custom), inferred from thePageSchemaZod 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.