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:
| Route | Purpose |
|---|---|
/my-collection/ | Listing page — shows all entries |
/my-collection/my-slug | Detail 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:
npm run add-collectionThe script will:
-
Show the collections that already exist.
-
Ask for a collection name — lowercase letters, numbers, and hyphens only (e.g.
projects,press-releases). -
Ask for a collection type to pick a starting schema:
Type Frontmatter fields blogtitle,description,date,author,tags,image,draftdocstitle,description,order,category,draftgenerictitle,description,draft -
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. -
Create or update these files automatically:
File What changes src/content.config.tsNew defineCollectionblock added; name registered in thecollectionsexportsrc/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:
npm run add-contentThe script will:
- List all collections that already have a content directory.
- Ask which collection to add the file to (by name or number).
- 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, orallto write an identical starting file into every language folder. Collections that aren’t split by language skip this step. See Multilingual collections below. - Ask for the file format —
mdormdx(defaults to whichever format is already used in that collection). - Ask for a slug — this becomes the file name and the URL path. You can include subfolders (e.g.
2026/my-first-post→ saved assrc/content/blog/2026/my-first-post.mdand reachable at/blog/2026/my-first-post). - Prompt for each frontmatter field declared in the schema, with smart defaults:
datefields default to today’s dateauthordefaults to yourgit config user.namedraftdefaults totrue- 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-10author: "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
TableOfContentscore 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 propsconst 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:
- Update every existing content file to include (or remove) the changed field, or mark new fields as
.optional()so old files remain valid. - 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.mdThere 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-collectionasks for the locale codes up front and writes one sample file into eachsrc/content/<name>/<locale>/folder.npm run add-contentnotices the language folders and asks which one the new file belongs in. Choosingall(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:
npm run images # convert, delete the originals, rewrite referencesnpm run images -- --dry-run # show the estimated savings, touch nothingnpm run images -- --check # touch nothing; exit 1 if something is left to convertIt 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-10draft: 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.