This site is a headless architecture: Strapi v5 stores the content and exposes a REST API, while Astro consumes it at build time to produce a fully static site. The flavors case, services, team, and about pages live in static data modules by design — only the blog flows through this pipeline. Here is how a single article travels from the CMS database to the HTML you are reading.
1. Environment and the fetch helper
Configuration is validated up front. STRAPI_URL and STRAPI_API_TOKEN are checked against a zod schema in src/config/env.ts at module load, so a malformed URL or missing token fails the build loudly instead of surfacing as an opaque fetch error. The token is read raw from import.meta.env and never re-exported, keeping it out of any client-reachable import graph.
Every request to Strapi then goes through one typed wrapper, fetchAPI<T>(). It takes an API path like /api/articles, serializes query params with the qs library, attaches a Bearer token when one is set, and returns the JSON as a typed promise T. The wrapper also:
- memoizes identical requests per build (keyed on path, params, token, and base URL) so duplicate calls collapse into a single network round-trip;
- enforces a 10-second
AbortControllertimeout per attempt; - retries on
408,429, and5xxresponses, and on network errors, with exponential backoff (up to three attempts, starting at one second); - short-circuits a
404into aContentNotFoundError, and a403into a plainStrapiError(both non-retryable); - throws a
ValidationErrorif a zod schema later rejects the response body.
2. Typed queries
On top of the helper sit focused query functions, one per content type. getArticles(limit) and getAllArticles() both hit /api/articles but shape the request differently. Strapi returns relations as IDs by default, so each query passes a populate array to pull nested fields in a single request — cover, category, author.avatar, and the dynamic blocks.file / blocks.files for media inside the article body. Every list query also pins publicationState: "live" (drafts never ship) and sort: ["publishedAt:desc"] (newest first).
The raw JSON is run through zod schemas (articleSchema, listResponseSchema) that mirror the TypeScript types (Article, StrapiListResponse<T>). A schema failure throws a ValidationError; a success is narrowed to the typed model, so the rest of the app never has to handle unknown.
3. Build-time fetching
Because Astro is used in static mode, data fetching happens once at build time, not on every request. The blog index calls getArticles(20) at the top of its frontmatter. The detail page goes further: its getStaticPaths() calls getAllArticles(), which follows Strapi's pagination across every page (100 articles per request) so a route is generated for each article slug. The fetched Article is passed straight through as a page prop, alongside a full allArticles array the page uses for related-article links — no client-side fetch, no loading state.
If pagination fails halfway, the build aborts with a contextual error: which page threw, how many had already loaded, and the underlying cause. A partial article set is never silently shipped.
4. Media → dist/uploads
Article images do not get fetched by the visitor's browser. A mediaUrl() helper resolves a Strapi upload path (or absolute URL) into the full CMS origin, and a custom Astro integration, integrations/static-cms-assets.mjs, runs after astro build. It scans every emitted HTML, XML, and JSON file for <cmsOrigin>/uploads/... references, downloads each unique file into dist/uploads/, and rewrites every URL to https://<PUBLIC_SITE_URL>/uploads/.... A failed download fails the build — a missing image must never ship silently. The result: visitors (and social crawlers reading og:image or JSON-LD) get every asset from the static host, and the Strapi instance serves nothing to the public.
5. Blocks → HTML
An article body is not a single markdown blob. Strapi v5 stores it as an array of dynamic-zone blocks, each tagged with a __component string: shared.rich-text, shared.quote, shared.media, or shared.slider. The <BlockRenderer> component maps over that array and renders the right markup for each component, narrowed at compile time by an ArticleBlock discriminated union.
Rich-text blocks run through the unified ecosystem: remark-parse parses the markdown into an mdast tree, remark-rehype converts it to a hast tree, rehype-sanitize strips dangerous markup against an explicit allowlist, a small rehypeSafeLinks plugin then injects rel="noopener noreferrer" onto any anchor still carrying target="_blank" (a nicety the sanitizer would otherwise strip), and rehype-stringify emits the final HTML for set:html. Editors can write markdown without opening an XSS surface.
6. The result
The end product is a folder of static HTML files in dist/, with every CMS image already baked in alongside the markup. The CMS can be edited, previewed, and published independently; a rebuild is all it takes for changes to go live. No API calls happen in the browser, which keeps the site fast and the Strapi instance private.