This guide explains how content is organised in s:CMS, how to scaffold new collections with a single command, and how to customise the pages that render them.


What is a collection?

A content collection is a folder of Markdown (.md) or MDX (.mdx) files that share the same shape — the same set of frontmatter fields. For example, a blog collection might contain one file per article, each with a title, a date, and a draft flag.

Collections are registered in src/content.config.ts, where a Zod schema declares exactly which frontmatter fields each collection expects and what type they must be. Astro validates every file against this schema at build time, so typos or missing required fields are caught before the site is published.

Each collection also gets two routes:

RoutePurpose
/my-collection/Listing page — shows all entries
/my-collection/my-slugDetail page — renders a single entry

These pages live in src/pages/my-collection/ and are plain Astro files you can edit freely.


Adding a new collection

Run the interactive scaffolding script:

Terminal window
npm run add-collection

The script will:

  1. Show the collections that already exist.

  2. Ask for a collection name — lowercase letters, numbers, and hyphens only (e.g. projects, press-releases).

  3. Ask for a collection type to pick a starting schema:

    TypeFrontmatter fields
    blogtitle, description, date, author, tags, image, draft
    docstitle, description, order, category, draft
    generictitle, description, draft
  4. Ask for language folders — leave blank for a single-language collection, or enter locale codes (e.g. en, it) to scaffold the sample file once per language folder. See Multilingual collections below.

  5. Create or update these files automatically:

    FileWhat changes
    src/content.config.tsNew defineCollection block added; name registered in the collections export
    src/content/<name>/sample-*.mdA ready-to-edit sample file with pre-filled frontmatter (one per language folder for a multilingual collection: src/content/<name>/en/sample-*.md, …)
    src/pages/<name>/index.astroListing page for all entries in the collection
    src/pages/<name>/[...slug].astroDetail page for individual entries

After scaffolding, open src/content.config.ts and look for the TODO comment inside the new block — that is where you adjust the schema fields to match your actual data needs.


Adding a content file to an existing collection

Run:

Terminal window
npm run add-content

The script will:

  1. List all collections that already have a content directory.
  2. Ask which collection to add the file to (by name or number).
  3. If the collection is organised into language folders (src/content/<collection>/en/…, .../it/…), ask which language the new file belongs in — a single locale, a comma-separated subset, or all to write an identical starting file into every language folder. Collections that aren’t split by language skip this step. See Multilingual collections below.
  4. Ask for the file format — md or mdx (defaults to whichever format is already used in that collection).
  5. Ask for a slug — this becomes the file name and the URL path. You can include subfolders (e.g. 2026/my-first-post → saved as src/content/blog/2026/my-first-post.md and reachable at /blog/2026/my-first-post).
  6. Prompt for each frontmatter field declared in the schema, with smart defaults:
    • date fields default to today’s date
    • author defaults to your git config user.name
    • draft defaults to true
    • Optional fields can be skipped by pressing Enter

The generated file looks like this:

---
title: "My First Post"
description: "A short description."
date: 2026-03-10
author: "Jane Smith"
tags: ["news"]
draft: true
---
# My First Post
<!-- Write your content here -->

Open the file, write your content, and set draft: false when the entry is ready to publish. The development server reloads automatically when you save.


Customising the listing and detail pages

The pages generated by npm run add-collection are intentionally minimal starting points. They live in src/pages/<collection-name>/ and are regular Astro components — edit them freely.

Listing page — index.astro

This page fetches all entries and renders a list or grid. Common things to customise:

  • Filter out drafts: the default already does .filter(e => !e.data.draft), but you can add more conditions (e.g. filter by tag or category).
  • Sort order: change .sort(...) to order by any frontmatter field.
  • Layout: swap the Bootstrap card grid for a table, a timeline, or any HTML structure you like.
  • Pagination: add a simple slice or use a library if the collection is large.

Example — keeping only entries tagged "featured":

const entries = (await getCollection('projects'))
.filter(e => !e.data.draft && e.data.tags?.includes('featured'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());

Detail page — [...slug].astro

This page receives a single entry as a prop, renders its body with <Content />, and displays the frontmatter fields around it. Common things to customise:

  • Add a sidebar with a table of contents (use the TableOfContents core component).
  • Show related entries by filtering the collection on a shared tag.
  • Display an image from entry.data.image.
  • Add previous / next navigation by sorting the collection and finding the adjacent entries.

Example — adding a next/previous navigation bar:

---
// inside getStaticPaths, pass neighbours as props
const sorted = entries.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return sorted.map((entry, i) => ({
params: { slug: entry.id.replace(/\.mdx?$/, '') },
props: { entry, prev: sorted[i + 1] ?? null, next: sorted[i - 1] ?? null },
}));
---
{prev && <a href={`/blog/${prev.id.replace(/\.mdx?$/, '')}`}>← {prev.data.title}</a>}
{next && <a href={`/blog/${next.id.replace(/\.mdx?$/, '')}`}>{next.data.title} →</a>}

Changing the layout

Both pages import BaseLayout by default. Replace it with any layout from src/layouts/ or create a new one that extends BaseLayout:

---
import BlogLayout from '../../layouts/BlogLayout.astro';
---
<BlogLayout title={entry.data.title}>
<Content />
</BlogLayout>

Editing the schema after creation

You can add, rename, or remove fields in src/content.config.ts at any time. Remember to:

  1. Update every existing content file to include (or remove) the changed field, or mark new fields as .optional() so old files remain valid.
  2. Update the listing and detail page templates to display the new field.

Multilingual collections

A site that serves more than one language keeps a collection’s content in per-language sub-folders:

src/content/docs/
├── en/
│ └── guides/getting-started.md
└── it/
└── guides/getting-started.md

There is no configuration flag for this — the scaffolding scripts detect it purely from the folder layout. A collection is treated as multilingual when every sub-folder inside it is a locale code (en, it, pt-BR, …) and no content files sit loose at the collection root.

  • npm run add-collection asks for the locale codes up front and writes one sample file into each src/content/<name>/<locale>/ folder.
  • npm run add-content notices the language folders and asks which one the new file belongs in. Choosing all (or a comma-separated subset) writes the same starting file into each language folder — the frontmatter and body are identical copies for you to translate by hand afterwards. The [i18n] Missing "<locale>" translation … build warning tells you which pages are still untranslated.

The generated listing/detail pages under src/pages/<name>/ render a single language. On a multilingual site, adapt them to your locale routing (this project uses one src/pages/[locale]/… dynamic-route layer — see src/utils/i18n.ts).


Optimizing images

Images dropped next to your content (src/content/…) are served as they are, so a few 5 MB photos can make a page heavy. The scms-optimize-images command keeps them light:

Terminal window
npm run images # convert, delete the originals, rewrite references
npm run images -- --dry-run # show the estimated savings, touch nothing
npm run images -- --check # touch nothing; exit 1 if something is left to convert

It converts every JPG/PNG to WebP (long side at most 2000 px, never enlarged), deletes the original and rewrites every reference in your .md/.mdx files — frontmatter such as image:, ![](…), relative and absolute paths. An image whose WebP would not be lighter is left as it is, and the verdict is saved in .scms-optimize-images.json (commit it) so it is not re-encoded on every run. WebP files already larger than the maximum are scaled down in place.

On a multilingual site, pages in a non-default language usually reuse the images of the default-language page. Declare the languages in src/user.config.mjs so the command can follow those references:

export const userConfig = {
i18n: { defaultLocale: 'en', locales: ['en', 'it'] },
images: {
maxSize: 2000, // px, long side
quality: 82, // WebP quality for JPGs
exclude: [], // folders to leave alone
},
};

All the images options and their defaults are listed in the scms-core README.

References from code are never rewritten. If an .astro/.tsx file points at an image that was converted (<img src="/about/team.jpg">), the command reports file and line and exits with an error, until you change it to the .webp path.

To make the build fail when an unoptimized image slips in, run the check before every build. It never modifies any file:

{
"scripts": {
"prebuild": "scms-optimize-images --check"
}
}

An optional pre-commit hook that checks only the staged images is described in the scms-core README.


Using MDX for richer content

If you choose the .mdx format, content files can import and render any component:

---
title: "My Interactive Post"
date: 2026-03-10
draft: false
---
import { DataTb } from '@lad-sapienza/scms-core';
# My Interactive Post
Here is a live data table:
<DataTb
source={{ type: 'csv', url: '/data/results.csv' }}
client:idle
/>

All core components (maps, galleries, data tables, etc.) are importable from @lad-sapienza/scms-core. See the components section of the documentation for the full list.