Astro Strapi Loader — agent skill
Scope
- Package:
@sensinum/astro-strapi-loader (peer astro ^6 || ^7, Strapi 5).
- Goal: Load Strapi at build time into Astro content collections with correct
populate graphs, dynamic zones, and i18n.
Install and base wiring
- Install
@sensinum/astro-strapi-loader (with peer zod / astro per your project).
- In
src/content.config.ts, await generateCollections(config, definitions) and spread the returned record into collections (or merge multiple calls—see below).
- Env:
STRAPI_URL, STRAPI_TOKEN when the API is protected.
- Token: Must allow Content API reads and Content-Type Builder
GET content types (schema source for generated Zod).
Configuration object (generateCollections first argument)
| Field |
Notes |
url |
Strapi base URL; no /api suffix (package requests /api/...). |
token |
Optional Bearer. |
strict |
Optional; stricter Zod from schema builder. |
headers |
Optional extra request headers. |
Defining entries (second argument)
Each item is one Strapi collection → one Astro defineCollection, keyed by collectionName or the Strapi type name.
| Field |
Purpose |
name |
API id / route segment (e.g. homepage, case-studies). |
query |
REST object: populate, filters, sort, fields, pagination. |
collectionName |
Astro collection key when reusing the same name (e.g. per-locale splits). |
locale |
string or string[] — see Multilingual. |
idGenerator |
(data) => string — else documentId. |
Shorthand: ['homepage', 'layout'] means default query for those types.
Merging several generateCollections results: the function returns a flat object of collection configs. You can await it once with a long definition list, or call it in a loop (e.g. one definition at a time) and spread into a single object: strapiCollections = { ...strapiCollections, ...result }. The loop pattern helps isolate failures (log per def, continue) when some endpoints error at build time.
Organizing large query values
Techniques that keep content.config.ts maintainable:
- Shared fragments — small
const objects (e.g. heroPopulate, seoPopulate) and compose the final query with spread so hero/SEO stay consistent across page types.
- One dynamic zone map — a single
const shaped as { [dzFieldName]: { on: { 'component.api.id': { populate: ... } } } }, then spread into populate (e.g. populate: { hero, seo, ...dynamicZoneFragment }). Add or trim components in one place.
- Dot-path
populate arrays — for flat but wide graphs, populate: ['a', 'a.b', 'a.b.c'] next to nested objects in the same populate where needed.
- Central query module — move exported
const query objects to e.g. src/globals/queries.ts (or lib/strapi-queries.ts) and import them into content.config.ts. The same object can be passed to the loader and, for runtime fetches, stringified with qs—one source of truth.
- Single types + collection types — list single-type and collection-type defs separately, then build a combined
collectionDefs (e.g. each entry { name, query, collectionName?, locale? }) before looping generateCollections.
Building query objects (Strapi REST)
Author data, not URLs. Aligned with REST API parameters: filters, locale, status, populate, fields, sort, pagination. Bracket encoding in the query string is produced by qs from that object.
Object form (deep populate)
Nested objects for relations, components, and dynamic zones, e.g. populate: { seo: { populate: ['ogImage'] } }.
Object–array (mixed) populate
Parameters allow populate as string or object. Array of dot-paths is useful for flatter trees:
populate: ['logo', 'seo', 'seo.ogImage', 'customer.logo_light']
You may mix a path array and nested on / objects under one populate.
Other parameters
sort: string or string array.
fields: array of names.
filters: object — Filters.
pagination: { page, pageSize } or as per current Strapi docs.
Anti-patterns
- Hand-built
?a=b&... strings, template literals, or manual bracket segments.
- Using
URLSearchParams for deep nested populate.
- Pre-stringifying inside
content.config.ts (the loader should receive a plain object).
Manual HTTP
import qs from "qs";
const queryParams = qs.stringify(
{ populate: { /* ... */ }, locale: "en" },
{ encodeValuesOnly: true }
);
Do not use JSON.stringify on the whole query in place of qs for the URL.
Serialization boundary
- Loader: pass one
query object; strapiLoader uses qs.stringify internally.
- Raw
fetch / fetchContent: build the same object, then qs.stringify(..., { encodeValuesOnly: true }).
Dynamic zone with on
The dynamic zone field (e.g. sections, blocks) is populated with on: only listed component API ids (as in the Content-Type Builder) are expanded, each with its own populate.
{
blocks: {
on: {
"blocks.rich_text": { populate: { body: true, cta: true } },
"blocks.media_row": { populate: { items: { populate: ["media"] } } },
},
},
}
Place that under the top-level populate next to hero, seo, etc. Deep trees inside on (nested components, media) use the same rules as any other populate object.
Collection types vs single types
- Collection:
data is an array — one content entry per item in the store.
- Single type:
data is a single object — one entry in the store.
The same StrapiCollection shape applies; routing is defined by the content type in Strapi.
Multilingual / locale
locale: 'en' (single): one request; Strapi returns that language.
- Same Strapi type → multiple Astro collections: repeat the entry with the same
name, different locale and different collectionName (e.g. pages-pl, pages-en) so getCollection('pages-pl') and getCollection('pages-en') are separate.
locale: ['en', 'de', 'pl']: the loader fetches per locale, stores ids as locale:documentId, adds _locale on each item’s data. Pairs well with idGenerator when public ids are slugs: en:about-us.
- The loader merges
locale into the request query; you still model locale in the object, not as a raw query string.
Using data in Astro
getCollection('key') — all entries; filter on data._locale for multi-locale single collection.
getEntry('key', 'locale:documentId' | 'locale:slug') when using prefixed ids.
- Runtime Strapi calls:
fetchContent with qs.stringify’d params, ideally reusing the same query object as build time.
Troubleshooting
- Empty or partial data:
populate depth, wrong locale, draft/unpublished, or filters too strict.
- 400 on REST: compare object shape to parameters and actual serialization via
qs.
- Build schema errors:
STRAPI_URL / token cannot reach Content-Type Builder or list content types.
1---2name: astro-strapi-loader-23description: Use @sensinum/astro-strapi-loader with Astro Content Layer: build Strapi REST query objects (object and object-array populate per REST parameters), let qs.stringify serialize—never inline query strings. Composable query fragments, dynamic zone "on", locale strategies, generateCollections, getCollection / getEntry. Strapi 5, Astro 6 or 7.4---56# Astro Strapi Loader — agent skill78## Scope910- **Package:** `@sensinum/astro-strapi-loader` (peer `astro` `^6 || ^7`, Strapi 5).11- **Goal:** Load Strapi at build time into Astro content collections with correct `populate` graphs, dynamic zones, and i18n.1213## Install and base wiring14151. Install `@sensinum/astro-strapi-loader` (with peer `zod` / `astro` per your project).162. In `src/content.config.ts`, `await generateCollections(config, definitions)` and spread the returned record into `collections` (or merge multiple calls—see below).173. **Env:** `STRAPI_URL`, `STRAPI_TOKEN` when the API is protected.184. **Token:** Must allow **Content API** reads and **Content-Type Builder** `GET` content types (schema source for generated Zod).1920## Configuration object (`generateCollections` first argument)2122| Field | Notes |23|--------|--------|24| `url` | Strapi base URL; no `/api` suffix (package requests `/api/...`). |25| `token` | Optional Bearer. |26| `strict` | Optional; stricter Zod from schema builder. |27| `headers` | Optional extra request headers. |2829## Defining entries (second argument)3031Each item is one **Strapi collection** → one Astro `defineCollection`, keyed by `collectionName` or the Strapi type name.3233| Field | Purpose |34|--------|-----------|35| `name` | API id / route segment (e.g. `homepage`, `case-studies`). |36| `query` | REST object: `populate`, `filters`, `sort`, `fields`, `pagination`. |37| `collectionName` | Astro collection key when reusing the same `name` (e.g. per-locale splits). |38| `locale` | `string` or `string[]` — see **Multilingual**. |39| `idGenerator` | `(data) => string` — else `documentId`. |4041**Shorthand:** `['homepage', 'layout']` means default `query` for those types.4243**Merging several `generateCollections` results:** the function returns a flat object of collection configs. You can `await` it once with a long definition list, or call it in a **loop** (e.g. one definition at a time) and **spread** into a single object: `strapiCollections = { ...strapiCollections, ...result }`. The loop pattern helps **isolate failures** (log per def, continue) when some endpoints error at build time.4445## Organizing large `query` values4647Techniques that keep `content.config.ts` maintainable:48491. **Shared fragments** — small `const` objects (e.g. `heroPopulate`, `seoPopulate`) and **compose** the final `query` with spread so hero/SEO stay consistent across page types.502. **One dynamic zone map** — a single `const` shaped as `{ [dzFieldName]: { on: { 'component.api.id': { populate: ... } } } }`, then spread into `populate` (e.g. `populate: { hero, seo, ...dynamicZoneFragment }`). Add or trim components in **one** place.513. **Dot-path `populate` arrays** — for flat but wide graphs, `populate: ['a', 'a.b', 'a.b.c']` next to nested objects in the same `populate` where needed.524. **Central query module** — move exported `const` query objects to e.g. `src/globals/queries.ts` (or `lib/strapi-queries.ts`) and **import** them into `content.config.ts`. The **same** object can be passed to the loader and, for runtime fetches, stringified with `qs`—one source of truth.535. **Single types + collection types** — list single-type and collection-type defs separately, then build a **combined** `collectionDefs` (e.g. each entry `{ name, query, collectionName?, locale? }`) before looping `generateCollections`.5455## Building `query` objects (Strapi REST)5657Author **data**, not URLs. Aligned with [REST API parameters](https://docs.strapi.io/cms/api/rest/parameters): `filters`, `locale`, `status`, `populate`, `fields`, `sort`, `pagination`. Bracket encoding in the query string is produced by **`qs`** from that object.5859### Object form (deep `populate`)6061Nested objects for relations, components, and dynamic zones, e.g. `populate: { seo: { populate: ['ogImage'] } }`.6263### Object–array (mixed) `populate`6465[Parameters](https://docs.strapi.io/cms/api/rest/parameters) allow `populate` as string or object. **Array of dot-paths** is useful for flatter trees:6667```ts68populate: ['logo', 'seo', 'seo.ogImage', 'customer.logo_light']69```7071You may mix a path array and nested `on` / objects under one `populate`.7273### Other parameters7475- `sort`: string or string array.76- `fields`: array of names.77- `filters`: object — [Filters](https://docs.strapi.io/cms/api/rest/filters).78- `pagination`: `{ page, pageSize }` or as per current Strapi docs.7980### Anti-patterns8182- Hand-built `?a=b&...` strings, template literals, or manual bracket segments.83- Using `URLSearchParams` for deep nested `populate`.84- Pre-stringifying inside `content.config.ts` (the loader should receive a **plain object**).8586### Manual HTTP8788```ts89import qs from "qs";9091const queryParams = qs.stringify(92 { populate: { /* ... */ }, locale: "en" },93 { encodeValuesOnly: true }94);95```9697**Do not** use `JSON.stringify` on the whole query in place of `qs` for the URL.9899### Serialization boundary100101- **Loader:** pass one `query` object; `strapiLoader` uses `qs.stringify` internally.102- **Raw `fetch` / `fetchContent`:** build the same object, then `qs.stringify(..., { encodeValuesOnly: true })`.103104## Dynamic zone with `on`105106The dynamic zone field (e.g. `sections`, `blocks`) is populated with **`on`**: only listed **component API ids** (as in the Content-Type Builder) are expanded, each with its own `populate`.107108```ts109{110 blocks: {111 on: {112 "blocks.rich_text": { populate: { body: true, cta: true } },113 "blocks.media_row": { populate: { items: { populate: ["media"] } } },114 },115 },116}117```118119Place that under the top-level `populate` next to `hero`, `seo`, etc. Deep trees inside `on` (nested components, media) use the same rules as any other `populate` object.120121## Collection types vs single types122123- **Collection:** `data` is an **array** — one content entry per item in the store.124- **Single type:** `data` is a **single object** — one entry in the store.125126The same `StrapiCollection` shape applies; routing is defined by the content type in Strapi.127128## Multilingual / `locale`129130- **`locale: 'en'` (single):** one request; Strapi returns that language.131- **Same Strapi type → multiple Astro collections:** repeat the entry with the same `name`, different `locale` and different **`collectionName`** (e.g. `pages-pl`, `pages-en`) so `getCollection('pages-pl')` and `getCollection('pages-en')` are separate.132- **`locale: ['en', 'de', 'pl']`:** the loader fetches per locale, stores ids as **`locale:documentId`**, adds **`_locale`** on each item’s data. Pairs well with `idGenerator` when public ids are slugs: `en:about-us`.133- The loader merges `locale` into the request query; you still model **`locale` in the object**, not as a raw query string.134135## Using data in Astro136137- `getCollection('key')` — all entries; filter on `data._locale` for multi-locale single collection.138- `getEntry('key', 'locale:documentId' | 'locale:slug')` when using prefixed ids.139- **Runtime** Strapi calls: `fetchContent` with **`qs.stringify`’d** params, ideally reusing the **same** query object as build time.140141## Troubleshooting142143- **Empty or partial data:** `populate` depth, wrong `locale`, draft/unpublished, or `filters` too strict.144- **400 on REST:** compare object shape to [parameters](https://docs.strapi.io/cms/api/rest/parameters) and actual serialization via `qs`.145- **Build schema errors:** `STRAPI_URL` / token cannot reach Content-Type Builder or list content types.