Black Weeks . Full Access for 50% OFF. Use code lex50 at checkout.

You'll get every theme available plus future additions. That's 40 themes total.Unlimited projects. Lifetime updates. One payment.

Get full access

Astro Content Collections: Real-World schemas you can steal

A production-ready Astro content config used in real client projects: blog posts, services, projects, properties, agents, legal pages, testimonials, FAQs, and recursive navigation.

Published on December 3, 2025 by Michael Andreuzza

If you are still shipping Astro sites with bare-minimum content schemas, you are leaving a lot of safety (and automation) on the table. Real client work needs strongly typed media objects, references, enums, and metadata structures that match how the marketing team actually maintains the site.

Over the past year I standardized on the following Astro Content Collections file for agency projects that mix SaaS marketing, services, portfolio work, and a real-estate directory. It keeps authors honest, gives CMS-level guardrails, and works beautifully with astro:content tooling.

What you get

In this post you’ll get:

  • A full config.ts with 10 production-ready collections.
  • Blog, services, projects, properties, agents, legal pages, testimonials, FAQs, and navigation.
  • Cross-collection references for properties ⇄ agents.
  • Recursive navigation that can power mega menus and complex footers.
  • Opinionated SEO, media, and metadata fields you can copy as-is or trim down.

We’ll walk through the collections first so you understand the shape and the tradeoffs, and at the end you’ll get the full file you can drop straight into /src/content/config.ts.

Collection breakdown

Blog posts

Every article stores hero media, author attribution, optional structured data for SEO, and boolean flags for drafts or homepage features. That means you can power Open Graph cards, reading time badges, and editorial workflows from a single source.

I keep an explicit slug field so URLs are not permanently tied to the folder/file structure. If marketing wants to refactor URL patterns later, you can migrate slugs without renaming half the repo.

Highlights:

  • hero media object with optional focal point, caption, and credit.
  • seo object for per-post meta, Open Graph, and structured data.
  • draft and featured booleans for editorial workflows.
  • readingTime as a number so you can render badges without re-calculating.

Services

Services behave like CMS entries used across pricing and landing pages. Ordered integers keep sections stable, and the optional CTA object wires up hero buttons without hardcoding URLs in layouts.

Because this is a data collection, you can drive it from JSON/YAML files if that’s easier for non-technical collaborators.

Highlights:

  • tagline + longDescription so you can use the same entry in cards and detail pages.
  • category enum to segment services (strategy, design, dev, etc.).
  • order as an integer to avoid “drag-and-drop” reordering inside templates.
  • Optional cta block for deep-link buttons.

Projects

The gallery array enforces alt text and dimensions (handy when you need responsive images). Metrics and link objects let you surface tangible results or case-study CTAs in cards.

Projects are content entries so they can hold longer narrative copy, code samples, or rich MDX blocks.

Highlights:

  • gallery as an array of media objects for case-study layouts.
  • metrics as reusable “results” chips (+38% conversions, etc.).
  • links block for live site and repo URLs.
  • status enum (planned, in-progress, launched, archived) for timeline views.

Properties + Agents

Properties and agents are content collections tied together by reference():

  • Each property points to an agent.
  • Each agent can optionally list properties back.

Cross-collection references keep listings in sync with agent bios, so you never duplicate contact data. Latitude/longitude pairs open the door to map overlays, neighborhood pages, or location-aware SEO snippets.

Highlights (properties):

  • status enum (available, sold, rented).
  • Structured address object for localized formatting.
  • size with value + unit (sqft/sqm).
  • gallery + optional floorPlans arrays.
  • agent: reference("agents") for cross-linking.

Highlights (agents):

  • socialLinks array with a platform enum.
  • Optional licenseId for markets that need it.
  • Optional properties: reference("properties")[] for reverse listing pages.

Marketing or legal teams can update policy content while keeping version numbers and noindex flags aligned with compliance. These live in content so you can use Markdown for formatting and headings.

Highlights:

  • lastUpdated date for compliance.
  • Optional version string for internal tracking.
  • seo block with noindex flag so policies don’t pollute search.

Testimonials & FAQs

Ratings, company, and category enums keep things sortable on landing pages, while weight lets you surface “priority” FAQs first.

Testimonials stay in data so you can reuse the same quotes across multiple themes or sections without dragging in Markdown formatting.

Highlights (testimonials):

  • rating 1–5 for star components.
  • featured boolean to pin specific quotes.
  • avatar image object for more trustworthy layouts.

Highlights (FAQs):

  • category enum (general, billing, technical, etc.) for tabbed layouts.
  • Optional weight field so you can order FAQs without hacks.

Recursive navigationItemSchema lets you model real megamenus and deep footers without rewriting shape definitions in multiple places.

The navigation collection itself is a simple data object with an items array:

  • Top-level items.
  • Nested children for dropdowns and secondary links.
  • Optional rel, external, and ariaLabel fields for accessibility and SEO.

How to use it

  1. Drop the full config (below) into /src/content/config.ts.
  2. Create folders for posts, services, projects, properties, agents, legal, testimonials, faqs, and navigation inside src/content.
  3. Start populating entries; the Zod schemas will throw helpful validation errors whenever someone tries to skip required media, forget tags, or mislabel a category.

This setup gives you CMS-grade safety without leaving the repo. Once a team adopts it, their designers and marketers can finally trust that what they ship in Markdown and data files actually matches the site’s requirements.

Full production config (copy-paste)

import { defineCollection, reference, z } from "astro:content";

const serviceCategories = z.enum([
  "strategy",
  "design",
  "development",
  "marketing",
  "maintenance",
]);

const projectStatus = z.enum([
  "planned",
  "in-progress",
  "launched",
  "archived",
]);

const projectCategories = z.enum([
  "brand",
  "web",
  "mobile",
  "commerce",
  "infrastructure",
]);

const propertyStatus = z.enum(["available", "sold", "rented"]);

const faqCategories = z.enum([
  "general",
  "billing",
  "technical",
  "legal",
  "sales",
]);

const navigationItemSchema = z.lazy(() =>
  z.object({
    label: z.string(),
    url: z.string(),
    ariaLabel: z.string().optional(),
    rel: z.string().optional(),
    external: z.boolean().optional(),
    children: z.array(navigationItemSchema).optional(),
  })
);

const posts = defineCollection({
  type: "content",
  schema: ({ image }) => {
    const media = z.object({
      src: image(),
      alt: z.string(),
      focalPoint: z
        .object({
          x: z.number().min(0).max(1),
          y: z.number().min(0).max(1),
        })
        .optional(),
      caption: z.string().optional(),
      credit: z.string().optional(),
    });

    const seo = z.object({
      title: z.string().optional(),
      description: z.string(),
      canonicalUrl: z.string().url().optional(),
      keywords: z.array(z.string()).min(1).optional(),
      noindex: z.boolean().default(false),
      ogImage: media.optional(),
      twitterImage: media.optional(),
      structuredData: z.record(z.unknown()).optional(),
    });

    return z.object({
      title: z.string(),
      slug: z.string(),
      description: z.string(),
      pubDate: z.date(),
      updatedDate: z.date().optional(),
      hero: media,
      tags: z.array(z.string()).min(1),
      readingTime: z.number().positive(),
      author: z.object({
        name: z.string(),
        role: z.string().optional(),
        avatar: media.optional(),
        url: z.string().url().optional(),
      }),
      seo,
      draft: z.boolean().default(false),
      featured: z.boolean().default(false),
    });
  },
});

const services = defineCollection({
  type: "data",
  schema: ({ image }) => {
    const serviceImage = z.object({
      src: image(),
      alt: z.string(),
      width: z.number().int().positive().optional(),
      height: z.number().int().positive().optional(),
    });

    return z.object({
      title: z.string(),
      slug: z.string(),
      tagline: z.string(),
      longDescription: z.string(),
      image: serviceImage,
      category: serviceCategories,
      order: z.number().int().nonnegative(),
      published: z.boolean().default(true),
      featured: z.boolean().default(false),
      cta: z
        .object({
          label: z.string(),
          url: z.string(),
        })
        .optional(),
    });
  },
});

const projects = defineCollection({
  type: "content",
  schema: ({ image }) => {
    const galleryItem = z.object({
      src: image(),
      alt: z.string(),
      caption: z.string().optional(),
      width: z.number().int().positive().optional(),
      height: z.number().int().positive().optional(),
    });

    return z.object({
      title: z.string(),
      client: z.string(),
      year: z.number().int(),
      description: z.string(),
      summary: z.string().optional(),
      gallery: z.array(galleryItem).min(1),
      categories: z.array(projectCategories).min(1),
      featured: z.boolean().default(false),
      status: projectStatus,
      metrics: z
        .array(
          z.object({
            label: z.string(),
            value: z.string(),
          })
        )
        .optional(),
      links: z
        .object({
          live: z.string().url().optional(),
          repo: z.string().url().optional(),
        })
        .optional(),
    });
  },
});

const properties = defineCollection({
  type: "content",
  schema: ({ image }) => {
    const propertyImage = z.object({
      src: image(),
      alt: z.string(),
      caption: z.string().optional(),
    });

    return z.object({
      title: z.string(),
      slug: z.string(),
      price: z.number().positive(),
      status: propertyStatus,
      address: z.object({
        street: z.string(),
        suite: z.string().optional(),
        city: z.string(),
        state: z.string(),
        postalCode: z.string(),
        country: z.string(),
      }),
      bedrooms: z.number().nonnegative(),
      bathrooms: z.number().nonnegative(),
      size: z.object({
        value: z.number().positive(),
        unit: z.enum(["sqft", "sqm"]).default("sqft"),
      }),
      location: z.object({
        lat: z.number().min(-90).max(90),
        lng: z.number().min(-180).max(180),
      }),
      gallery: z.array(propertyImage).min(1),
      floorPlans: z.array(propertyImage).optional(),
      agent: reference("agents"),
      tags: z.array(z.string()).min(1),
      amenities: z.array(z.string()).optional(),
      featured: z.boolean().default(false),
      published: z.boolean().default(true),
    });
  },
});

const agents = defineCollection({
  type: "content",
  schema: ({ image }) => {
    const avatar = z.object({
      src: image(),
      alt: z.string(),
    });

    return z.object({
      name: z.string(),
      slug: z.string(),
      role: z.string(),
      avatar,
      phone: z.string(),
      email: z.string().email(),
      bio: z.string(),
      licenseId: z.string().optional(),
      socialLinks: z
        .array(
          z.object({
            platform: z.enum([
              "instagram",
              "facebook",
              "linkedin",
              "twitter",
              "youtube",
              "tiktok",
              "website",
            ]),
            url: z.string(),
            handle: z.string().optional(),
          })
        )
        .min(1),
      properties: z.array(reference("properties")).optional(),
    });
  },
});

const legalPages = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    slug: z.string(),
    content: z.string(),
    lastUpdated: z.date(),
    version: z.string().optional(),
    seo: z.object({
      title: z.string(),
      description: z.string(),
      canonicalUrl: z.string().url().optional(),
      noindex: z.boolean().default(false),
    }),
  }),
});

const testimonials = defineCollection({
  type: "data",
  schema: ({ image }) => {
    const avatar = z.object({
      src: image(),
      alt: z.string(),
    });

    return z.object({
      name: z.string(),
      role: z.string(),
      company: z.string(),
      quote: z.string(),
      avatar,
      rating: z.number().min(1).max(5),
      location: z.string().optional(),
      featured: z.boolean().default(false),
    });
  },
});

const faqs = defineCollection({
  type: "data",
  schema: z.object({
    question: z.string(),
    answer: z.string(),
    category: faqCategories,
    weight: z.number().int().optional(),
  }),
});

const navigation = defineCollection({
  type: "data",
  schema: z.object({
    items: z.array(navigationItemSchema),
  }),
});

export const collections = {
  posts,
  services,
  projects,
  properties,
  agents,
  legal: legalPages,
  testimonials,
  faqs,
  navigation,
};

Astro’s image() helper only accepts files inside your src/images directory. If you reference anything from public/ or an external URL, it will throw a build error.

Ready to build?

This config gives you a strong starting point for almost any marketing site: services, projects, real estate, navigation, legal pages, and more. It’s the structure I rely on when shipping work for agencies and product teams who can’t afford messy content or inconsistent metadata.

If you want an even stronger head start, all Lexington themes are built on top of these patterns—battle-tested collections, clean components, and layouts that scale. Grab one, drop this schema in, and you’re basically ready for production.

Either way: use this, steal from it, and ship something great.

/Michael Andreuzza

Did you like this post? Please share it with your friends!