Skip to content

Collection schema

The loader ships a default Zod schema covering the full article, so entry.data gives you author, tags, language, canonical, and timestamps without re-deriving anything. You only define a schema yourself if you want to narrow or extend it.

Every entry validates against this shape:

fieldtypenotes
slugstringURL-safe slug — also the entry id and your route param
titlestringon-page title (your <h1>)
seoTitlestringSEO <title> (falls back to title server-side)
seoDescriptionstring | nullmeta description
canonicalUrlstring | nullabsolute canonical — you decide self vs syndication source
formatTypestringschema.org type hint (e.g. "Article")
inLanguagestringBCP-47 language tag (e.g. "en")
tagsstring[]topical tags (possibly empty)
imageobject | nullcover image, or null (see below)
authorAuthorbyline — always present (see below)
publishedAtDatepublish date (coerced from the feed’s ISO string)
createdAtDaterow-created date
updatedAtDaterow-updated date — use for lastmod / dateModified
ghostwritrIdstringthe Ghost Writr article id, for round-tripping / debugging

The Markdown is not in entry.data. The raw body is on entry.body, and the compiled body is on entry.renderedrender(entry) is what turns it into <Content />. See Rendering articles.

null when the article has no generated cover. Otherwise:

{
url: string;
alt: string;
width: number | null;
height: number | null;
srcset: string | null;
}

Always present:

{
name: string;
slug: string;
bio: string | null;
avatarUrl: string | null;
jobTitle: string | null;
sameAs: string[];
kind: "real" | "persona";
}

author.kind drives JSON-LD: "real" is a person (schema.org Person), "persona" is a brand byline (Organization). See SEO & JSON-LD.

Per Astro’s loader contract, a schema you pass to defineCollection replaces the loader’s default — it is not merged. So if you supply one, you own the whole shape: any field you drop is gone from entry.data, and Astro validates each entry against yours.

A narrowed schema keeps entry.data lean:

src/content.config.ts
import { defineCollection, z } from "astro:content";
import { ghostwritr } from "@ghostwritr/astro";
export const collections = {
articles: defineCollection({
loader: ghostwritr({ siteId: import.meta.env.GHOSTWRITR_SITE_ID }),
schema: z.object({
slug: z.string(),
title: z.string(),
seoDescription: z.string().nullable(),
publishedAt: z.coerce.date(),
}),
}),
};

The slug is the entry id and your route param, so two articles sharing a slug would nondeterministically overwrite each other. The loader refuses: it throws a GhostwritrError with code INVALID_RESPONSE (and the offending slug in details), failing the build rather than shipping ambiguous content. See Error handling.