5 min read

Fetching the CMS once instead of once per page

This site used to ask Strapi for the same content on every page it built. Now a loader fetches each source once per build and every page reads a local store. Here is what changed, what fell out for free, and the three things that bit.

AstroStrapiTypeScript

Every page on this site is prerendered. There is no server at request time, so every read of the CMS happens while the build is running — that part was always true and still is. What was wrong was how many times it happened.

The shared page layout renders a footer that needs my profile. So the profile was fetched once per page. Not once per build: once per page. This site builds 136 pages, so a single content type that changes maybe twice a year was pulled from Strapi 136 times per deploy. Projects were fetched by the projects index, again by each project's detail page, and again by the search index. Two features had grown hand-rolled promise caches purely to paper over it.

flowchart LR
  p1["/ "] --> d1["data.ts"]
  p2["/projects"] --> d2["data.ts"]
  p3["…134 more pages"] --> d3["data.ts"]
  d1 --> cms[("Strapi")]
  d2 --> cms
  d3 --> cms

One loader per source

Astro's Content Layer inverts this. Instead of a page pulling data when it renders, you declare a collection with a loader, and the loader runs once per sync — before any page renders — writing its results into a local content store. Pages then read the store.

There are now fourteen collections, one per CMS source. The whole of content.config.ts is a manifest; the fetching stays in the modules that always owned it.

flowchart TD
  subgraph sync["Sync — runs once per build"]
    loader["Loader"] --> api["api.ts"]
    api --> cms[("Strapi")]
    loader --> store[("Content store")]
  end
  subgraph render["Render — runs once per page"]
    pages["136 pages"] --> store
  end

The accessor functions kept their names and signatures, so not a single page or component changed. getProfile(locale) still returns a profile — it just reads an entry now instead of making a request.

Entry ids carry the locale: en/abc123, ar/abc123. A consumer picks a locale with a prefix filter, and because the id is the locale plus the key, there is no separate locale field that can disagree with it. Single types skip the key entirely and use a bare en / ar, which turns the read into an exact lookup rather than a search.

One detail that looks fussy and is not: each entry stores an explicit order field, taken from its position in the API's already-sorted response. The store's iteration order is an implementation detail, and relying on it would mean the sort I asked Strapi for could quietly stop surviving the trip.

Three things that disappeared

The interesting part of this migration was how much code it deleted rather than added.

The caches went. Both hand-rolled promise caches existed only to stop repeat fetches. With the store there is nothing to memoize, so they went, along with the comments explaining why they were needed.

The filtered duplicate queries went. "Give me only the featured projects" used to be a second request with a filter. It is now .filter() over a set the loader already has. Same for skill tags, same for the home page's timeline highlights.

The translation fallback got honest. A document that has no row for a locale used to mean a 404 from Strapi, caught by class, falling back to the default locale. Now it means there is no entry at ar/abc123. Missing data expressed as missing data, instead of as an exception:

const localized = await getEntry('projects', `${locale}/${canonical.documentId}`);
return localized?.data ?? canonical;

Three things that bit

astro check now needs the CMS to be reachable, and that surprised me. Typechecking does not depend on the data — getCollection('projects') is typed from the collection's schema, not from the rows. But astro check runs a sync first, and a sync does two things at once: it generates the content types, which needs no CMS, and it runs every loader, which does. There is no way to ask for only the first.

flowchart LR
  check["astro check"] --> sync["astro sync"]
  sync --> types["Generate types<br/>(no CMS needed)"]
  sync --> loaders["Run loaders<br/>(CMS needed)"]
  types --> tsc["Typecheck"]

So CI now holds real read credentials. The same mechanism explains a smaller annoyance: in dev, a CMS edit needs a restart, not a refresh, because loaders run at sync.

TypeScript will not give an interface an index signature. Astro's parseData is typed TData extends Record<string, unknown>, and TypeScript grants implicit index signatures to mapped types and type-literal aliases — but never to an interface, and not through a bare alias to one either. Half my content types are interfaces. The tempting fix is to rewrite each one as { [K in keyof T]: T[K] }, which does work and loses nothing. The right fix was to stop letting a generic constraint dictate the shape of my domain types, leave the bound off my own factories, and cast once at the boundary.

astro:content is server-only, and that is a leak waiting to happen. This repo already had one: a chart island imported a module that reached the Strapi client and, through it, a server-only virtual module, and the island failed to hydrate with a 500. The comment recording that incident is still in the file. This migration recreated the same hazard by a new route, because every data module now imports astro:content.

Type-only imports are erased at build, so they are safe — which is the only reason the existing boundary held. That is far too subtle to leave to memory, so there is now a test that walks the import graph from every client entry point and fails on a runtime import of a server-only module. Its first version passed while a real violation sat in the tree, because it only checked direct imports of one file extension. A guard you have not seen fail is not a guard.

Where it landed

Fourteen loaders, each running exactly once per build; 796 collection entries across four locales; 136 pages. The build is not dramatically faster — Strapi is on the same machine and was never the bottleneck — but the shape is finally right, and the amount of code that exists purely to work around the old shape is now zero.