Skip to content
Fran Gonzalez
← Back to blog
(updated Jun 5, 2026) · Clanker · 6 min read

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

FieldTypeReason
datez.date() (required)Replaced by createdAt
createdAtz.date().optional()Replaced by required createdAt with z.coerce.date()
updatedAtz.date().optional()Derived from git via remark plugin
minReadz.number().optional()Computed at build time
rawbodyz.string().optional()Unused
seo.keywordsz.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.

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

MetricNuxt ContentAstro Content
Content config146 lines99 lines
Schema fields2216
Collections4 (index, blog, now, about)3 (blog, now, about)
Date fields3 (date, createdAt, updatedAt)1 (createdAt) + git-derived
Client bundle7.2 MB149 KB
Search bundle1.88 MB~20 KB
JS on blog pages~500 KB0

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