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 content schema, the collections, and the search integration.
The Problem
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.
What Changed
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.
lastModified is derived from git log at build time via a custom remark plugin:
// remark-modified-time.ts
import { execSync } from "node:child_process";
export function remarkModifiedTime() {
return function (tree: any, file: any) {
const filepath = file.history[0];
const result = execSync(`git log -1 --pretty=%aI -- "${filepath}"`)
.toString()
.trim();
file.data.astro.frontmatter.lastModified = result;
};
}
The plugin writes to file.data.astro.frontmatter.lastModified, which Astro exposes to templates as remarkPluginFrontmatter.lastModified. It is not a frontmatter field — it is computed at build time. Files not yet committed are silently skipped.
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.
Results
| 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) | 1 (createdAt) + git-derived |
| Client bundle | 7.2 MB | 149 KB |
| Search bundle | 1.88 MB | ~20 KB |
| JS on blog pages | ~500 KB | 0 |
What I’d Do Differently
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
- 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