Migrating from Nuxt Content to Astro Content
What changed when I replaced Nuxt Content's schema and collections with Astro's content collections.
I migrated frangonf.com from Nuxt Content v3.13 to Astro Content. The migration covered the content schema, the collections, and the search integration.
Migration scope
The Nuxt Content config (content.config.ts, 146 lines) used defineContentConfig from @nuxt/content with five factory functions (createBaseSchema, createImageSchema, createVideoSchema, createGallerySchema, createAuthorSchema). The schema had 22 fields, three date fields (date, createdAt, updatedAt), and now/about pages as YAML files with a content string field.
The Astro content config (src/content.config.ts, 99 lines) uses defineCollection from astro:content with glob() loaders and 16 schema fields.
Astro implementation
Content Config
The old Nuxt config defined collections with type: 'page' and source strings:
// content.config.ts (deleted)
import { defineCollection, defineContentConfig, z } from "@nuxt/content";
export default defineContentConfig({
collections: {
blog: defineCollection({
type: "page",
source: "blog/*.md",
schema: z.object({
title: z.string(),
description: z.string(),
minRead: z.number().optional(),
date: z.date(),
createdAt: z.date().optional(),
updatedAt: z.date().optional(),
draft: z.boolean().optional().default(false),
featured: z.boolean().optional().default(false),
author: z
.union([z.string(), createAuthorSchema()])
.optional()
.default("Fran"),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional(),
series: z.object({ name: z.string(), order: z.number() }).optional(),
image: z.string().nonempty().editor({ input: "media" }).optional(),
rawbody: z.string().optional(),
seo: z
.object({
title: z.string().optional(),
description: z.string().optional(),
keywords: z.array(z.string()).optional(),
})
.optional(),
}),
}),
},
});
The new Astro config defines collections with glob() loaders:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
createdAt: z.coerce.date(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
author: z.string().default("Fran"),
tags: z.array(z.string()).default([]),
category: z.string().optional(),
series: z.object({ name: z.string(), order: z.number() }).optional(),
image: image().optional(),
seo: z
.object({
title: z.string().optional(),
description: z.string().optional(),
})
.optional(),
}),
});
const now = defineCollection({
loader: glob({ pattern: "now.md", base: "./src/content" }),
schema: z.object({
title: z.string(),
description: z.string(),
createdAt: z.coerce.date(),
seo: z
.object({
title: z.string().optional(),
description: z.string().optional(),
})
.optional(),
}),
});
const about = defineCollection({
loader: glob({ pattern: "about.md", base: "./src/content" }),
schema: z.object({
title: z.string(),
description: z.string(),
seo: z
.object({
title: z.string().optional(),
description: z.string().optional(),
})
.optional(),
}),
});
export const collections = { blog, now, about };
The now and about collections changed from YAML files (now.yml, about.yml) to Markdown files (now.md, about.md). The content string field was removed; the Markdown body is the content.
The index collection (sourced from index.yml) was removed entirely.
Fields Removed
| Field | Type | Reason |
|---|---|---|
date | z.date() (required) | Replaced by createdAt |
createdAt | z.date().optional() | Replaced by required createdAt with z.coerce.date() |
updatedAt | z.date().optional() | Derived from git via remark plugin |
minRead | z.number().optional() | Computed at build time |
rawbody | z.string().optional() | Unused |
seo.keywords | z.array(z.string()).optional() | Unused |
author (object form) | z.union([z.string(), createAuthorSchema()]) | Simplified to z.string() |
The five factory functions (createBaseSchema, createImageSchema, createVideoSchema, createGallerySchema, createAuthorSchema) were removed. All schemas are inlined.
Image Handling
The image field changed from z.string().nonempty().editor({ input: "media" }).optional() to image().optional(): Astro’s image helper for content collection schemas.
The image() helper returns an image metadata object, not a string. I updated every template using <img src={post.data.image}> to <Image src={post.data.image} /> from astro:assets. The getImage() function processes images for OG meta tag URLs.
Frontmatter uses relative paths:
image: ./images/my-cover-photo.jpeg
Date Schema
createdAt uses z.coerce.date() instead of z.date(). The coercion handles string dates in frontmatter without validation errors.
The schema now also carries two optional, hook-managed date fields:
publishedAt: when the post first went live (draft: false).updatedAt: the last meaningful edit after publishing.
Both are derived from git history by the pre-commit hook (pnpm sync:timestamps) and stored in frontmatter. Public surfaces (listings, RSS, SEO, and the post UI) read publishedAt, falling back to createdAt when absent, and show an (updated …) note only when updatedAt is strictly after the publish date.
Storing the dates in frontmatter removes the build-time git dependency. The previous approach used a custom remark plugin that called git log during astro build; it silently produced no dateModified in containers without .git or git installed.
Pagefind Search
The old site used Nuxt UI’s <UContentSearch> component with SQLite WASM and Fuse.js, 1.88 MB of client-side JavaScript. Pagefind indexes at build time and outputs a static search bundle.
The build command chains the two steps:
{
"build": "astro build && pagefind --site dist"
}
Pagefind loads via dynamic import() with @vite-ignore to avoid Vite’s import analysis. The @vite-ignore comment is necessary because Pagefind JS does not exist during dev mode:
// src/layouts/BaseLayout.astro
const loadPagefind = async () => {
if (pagefind) return pagefind;
try {
pagefind = await import(/* @vite-ignore */ "/pagefind/pagefind.js");
await pagefind.init();
} catch {
console.log("Pagefind not available in dev mode");
}
return pagefind;
};
The Pagefind JavaScript API provides search(), debouncedSearch(), and preload() methods. Results load asynchronously via result.data() to minimize bandwidth. The search UI is a native <dialog> element with no additional library.
The pagefind.yml config sets root_selector: "body". The <main> element in BaseLayout.astro has data-pagefind-body to scope the indexable region.
Memento Mori as Vue Island
The Memento Mori page uses the @astrojs/vue integration with client:load for immediate hydration. It is the only page that ships JavaScript to the client.
// src/pages/mm.astro
---
import MementoMori from "../components/MementoMori.vue";
---
<MementoMori client:load />
The component was ported from the Nuxt version. I replaced useLocalStorage with a custom composable, replaced the Nuxt Icon component with inline SVGs, and kept temporal-polyfill for date arithmetic.
Verification
| Metric | Nuxt Content | Astro Content |
|---|---|---|
| Content config | 146 lines | 99 lines |
| Schema fields | 22 | 16 |
| Collections | 4 (index, blog, now, about) | 3 (blog, now, about) |
| Date fields | 3 (date, createdAt, updatedAt) | 3 (createdAt, publishedAt, updatedAt) |
| Client bundle | 7.2 MB | 149 KB |
| Search bundle | 1.88 MB | ~20 KB |
| JS on blog pages | ~500 KB | 0 |
Tradeoffs
The image() helper returns an object, not a string. Every template that touched image needed updating. This is documented in the Astro Images guide but easy to miss when migrating from Nuxt Content where image was z.string().
Four posts have orphaned minRead fields in frontmatter; Astro silently ignores them since the field is not in the schema.
computeReadingTime is duplicated across five files (blog index, slug, tag, category, author pages). It could be extracted to src/utils/dates.ts.
Vitest and Playwright tests pass locally (48 and 46 respectively) but neither pnpm test nor pnpm test:e2e appears in the GitHub Actions CI workflow. The CI validates linting, formatting, type checking, and build: not the test suites themselves.
References
- Astro Content Collections. Collection definition with
defineCollectionandglob()loaders. - Nuxt Content: Define Collections.
defineContentConfiganddefineCollectionwithtypeandsource. - Astro Images.
image()helper,<Image />component, andgetImage(). - Astro: Add Last Modified Time. Remark plugin recipe for git-derived timestamps (the approach this project used before moving dates into frontmatter).
- Astro Vue Integration.
client:loaddirective and island architecture. - Astro: Migrate from NuxtJS. Official migration guide.
- Pagefind. Static search indexing.
- Pagefind JavaScript API.
init(),search(),debouncedSearch(),preload(). - Nuxt UI.
<UContentSearch>component used in the previous setup.
This post was written with AI assistance.