← Back to site

Content Collections

All website content is managed through Astro content collections with Zod schemas that validate frontmatter at build time. Schemas are defined in src/content/config.ts.

Collections Overview

CollectionFolderLocalizedKey Fields
eventssrc/content/events/{en,de}/Yestitle, description, date, categoryEvent, coverImage (+ optional coverImageDescription for /media — captures caption + credit in one field), isDraft. Inline <Img /> body images surface on /media too (unless displayInMedia: false per-image).
projectssrc/content/projects/{en,de}/Yestitle, description, date, categoryProject, coverImage (+ optional coverImageDescription for /media — captures caption + credit in one field), isDraft, isProjectCompleted, person (→ people). Inline <Img /> body images surface on /media too (unless displayInMedia: false per-image).
peoplesrc/content/people/No (roles inline per locale)name, roleEn, roleDe, coverImage (optional — falls back to defaultFaceImage), coverImageDescription? (caption + credit in one field), showInFaces (consent toggle for both Faces of BEARS and the Media-page People accordion), order
testimonialssrc/content/testimonials/list.mdx (single-entry collection, body empty)No (quotes inline per locale)items: array of { person (→ people), quoteEn, quoteDe } — array order IS display order
page-textsrc/content/page-text/{en,de}/Yestitle, subtitle, description, ctas, items, address, socialLinks, navLinks, navColumns, faqs, mediaCategories, tierDescriptions, donate fields
sponsorssrc/content/sponsors/<tier>/Noname, order, logo, alt? (falls back to name), url, bgColor (tier from folder)
hero-slidessrc/content/hero-slides/Noorder, alt, shownText (corner overlay on homepage hero AND /media caption when surfaced), media (conditional: {discriminant: image|video, value}), displayInMedia
instagramsrc/content/instagram/Nourl, date, isDraft
docssrc/content/docs/Notitle, description, order, group
landing-section-visibility, about-section-visibility, sponsors-section-visibility, contact-section-visibility, media-section-visibilitysrc/content/<page>-section-visibility/settings.yaml (one YAML file each)NoBoolean show* toggles per page, edited via the <page> — segments singletons. Pages read these to skip whole sections at render time.

Localized collections use en/ and de/ subfolders with identical filenames in each. Content queries filter by locale and fall back to English if a German translation is missing.

Image Validation

All image filename fields (coverImage, logo) are validated against IMAGE_EXTENSION_REGEX from src/utils/imageConstants.ts. Only .jpg, .jpeg, .png, .webp, and .svg are accepted. The validation runs through a shared validateImageExtension helper in the config file.

Schema Patterns

isDraft Filtering

Events, projects, and Instagram posts support an isDraft field:

isDraft: z.boolean().default(false).optional()

In development mode, all entries are visible. In production, entries with isDraft: true are filtered out by filterDrafts() in src/utils/contentQueries.ts.

coverImageType Derivation

Events and projects schemas use a .transform() to derive a coverImageType field:

.transform((data) => {
  const coverImageType = data.coverImage ? "CUSTOM" : "DEFAULT";
  return { ...data, coverImageType };
})

This discriminator is used by the image loader to decide whether to load a custom image or fall back to the default placeholder. Since coverImage is a required field, coverImageType will always be "CUSTOM" in practice. The "DEFAULT" path exists as a safety fallback but won’t trigger under normal validation.

Conditional Validation (Projects)

Projects support a “Meet the Team” feature linked to the people collection. When displayMeetTheTeam is true, the person reference must be set:

.refine(
  (data) => !(data.displayMeetTheTeam === true && !data.person),
  { message: "person is required when displayMeetTheTeam is true", path: ["person"] }
)

Cross-Collection References

The projects.person field is a reference into the people collection, declared with Astro’s reference() helper:

person: reference('people').optional()

Keystatic’s matching field is fields.relationship({ collection: 'people' }). The frontmatter stores the slug as a plain string (e.g. person: "jane-doe"); at runtime it surfaces as { collection: 'people', id: 'jane-doe' }. Resolve via getEntry('people', slug) or by indexing a pre-loaded getCollection('people') map (the latter is what getMeetTheTeamProjectsWithPeople does in src/utils/contentQueries.ts).

The testimonials collection uses the same pattern — person: reference('people') — to attach each quote to a person.

Follow the same pattern (reference() + fields.relationship()) for any future cross-collection references.

Discriminated Unions (Hero Slides)

Hero slides use a z.discriminatedUnion on the type field to support two media types:

  • image — requires alt (string)
  • videoalt is optional

Both share media (filename) and shownText (optional overlay text). A separate validateMediaExtension helper additionally allows .mp4, .webm, and .ogg extensions.

Sponsors don’t declare a tier in frontmatter. The tier is derived from the subfolder name:

src/content/sponsors/
├── diamond/
├── platinum/
├── gold/
├── silver/
└── bronze/

The query function getSponsorsByTier() in contentQueries.ts groups entries by extracting the first path segment of their slug (e.g., gold/acme-corp → tier gold).

Page Text Flexible Schema

The page-text collection uses a flexible schema with title as the only required field. All other fields are optional, used by different page sections as needed:

FieldTypePurpose
titlestringSection heading (required)
subtitlestring?Secondary heading
descriptionstring?Body copy
seoDescriptionstring?Meta description override
buttonText / buttonHrefstring?Primary CTA (must be set together)
secondButtonText / secondButtonHrefstring?Secondary CTA (must be set together)
ctasarray (max 4)?Multiple CTAs with title, description, href
itemsstring[]?Simple list items
addressstring?Multi-line text block (footer address)
room / schedulestring?About Us “Where to find us” room label and meeting time
mapLat / mapLngnumber?About Us “Where to find us” map pin coordinates (lat −90…90, lng −180…180)
socialLinksarray?platform (reference to the social-platforms collection) + URL + optional hoverColor override
navLinksarray?Label + href pairs
navColumnsarray?Grouped navigation (heading + links)
faqsarray?Question + answer pairs
instagramButtonTextstring?Instagram CTA button label
mediaCategoriesarray?Media page categories, each with id and label
tierDescriptionsobject?Sponsor tier descriptions, keyed by tier name (diamond?, platinum?, gold?, silver?, bronze?)
accountHolder / bankName / iban / bic / referencestring?Donation card bank-transfer details
paypalUrl / paypalButtonTextstring?Donation card PayPal button (must be set together)
rememberLabelstring?About Us “Where to find us” small uppercase label above the room/schedule
emailLabel / addressLabel / mapLinkTextstring?Contact page email / address card headings and the view-on-map link text
followLabelstring?Contact page “Follow Us” card heading
showMoreText / showLessTextstring?Landing page Latest News mobile toggle labels
orDividerText / bankToggleTextstring?Sponsors page “Or” divider and bank-details toggle label

The schema uses .refine() to enforce that button fields always come in pairs — setting buttonText without buttonHref (or vice versa) will fail validation at build time. The same applies to secondButtonText / secondButtonHref.

Most pages share the flat schema and ignore the fields they don’t use. The “outlier” shapes — hero CTAs, FAQ, social links, footer nav columns, donate card, sponsor tier descriptions — live in dedicated files at the locale root (hero.mdx, faq.mdx, social.mdx, nav-columns.mdx, donate.mdx) so each one can be edited through its own focused Keystatic singleton. The Media page splits into two files under the media/ subfolder (media/media-title.mdx for the page header, media/media-categories.mdx for the category list), each driven by its own singleton.

Adding a New Collection

  1. Define the Zod schema in src/content/config.ts
  2. Add it to the collections export at the bottom of the file
  3. Create the content folder under src/content/<name>/ (use en/ and de/ subfolders if the content needs translation)
  4. If images are needed, create an asset folder in src/assets/<name>/ and add a glob pattern in src/utils/imageGlobs.ts
  5. Add query functions in src/utils/contentQueries.ts (use filterByLocale() for localized collections)
  6. If the collection has categories, add a Zod enum in src/types/content.ts and labels in src/utils/i18n.ts (categoryLabels)