SSG Astro with Headless Craft CMS Content Fetched At Build Time Or Cached In Advance

This guide builds an Astro site, in prerendered (aka SSG) mode, with content modeled and managed in Craft CMS, fetched from the CMS with GraphQL, and cached as static JSON — CMS data can be fetched at build time, or in advance.
🆒 The exciting thing about putting a static front end on a Craft site is that the CMS can be local only. What’s exciting about caching CMS content is the build can be run from an environment that can’t connect to the CMS — you can have local-only Craft, and rebuild your site with a JS continuous deployment service like Netlify, Vercel, et al.!
Read More
This article is the fourth in a series on headless Craft. The first part covers setting up a monorepo for headless Craft CMS. The second part covers on-demand rendered (aka SSR) Astro with headless Craft. The third part covers SSG Astro with CMS content fetched at build time.
Read the code
Prefer to study code? This article’s project is in the olets/astro-craftcms repo’s ssg-with-cached-data branch.
Interested in the diff between SSG Astro + Craft CMS with and without cached static data? There’s a pull request for that.
Here’s how it works:
-
The project is a monorepo with packages for the Astro front end and the Craft CMS back end.
-
The front end package’s Astro page URIs match the back end Craft CMS data model sections’ entry URI formats.
-
Pages for Craft singles, and pages that use arbitrary content (the equivalent of headed Craft’s routes), use static Astro routes.
-
Pages for Craft CMS channels and structures use dynamic Astro routes.
-
CMS content can be cached as Astro content collections.
-
The dev environment and the production build can either fetch content from the CMS or read cached data. In both cases an environment variable is used to toggle between the two.
Set up the monorepo, its CMS package, and its Astro package
First, follow the monorepo setup in my article Monorepo Setup for Headless Craft CMS.
Why’s it in a different place?
I know, it’s a little unusual to offload instructions to a different article. But it helps with maintenance, means folks who’ve been following along with these articles as they’ve come out over the past few months can skip over the parts they already know, and lets this article focus on what’s unique to its implementation.
Then follow the Craft CMS package setup in that same article. When you get to the app package instructions, come back here.
Then follow the Astro package setup instructions in my article SSR Astro With Headless Craft CMS.
Then come back here.
Bring Craft online by cding to packages/cms and running <your package manager> run start, replacing <your package manager> with your package manager (e.g. bun run start). Or if you added the optional top-level shorthand package scripts written up in Monorepo Setup For Headless Craft CMS, you could instead run <package manager> run cms start from the project root or from ./packages.
Add dependencies to the Astro package
Add just one, actually: zod.
Run <your package manager> add -D zod, replacing <your package manager> with your package manager (e.g. bun add -D zod).
Add environment variables to the app
API URL
Follow the env var typing and API env var creation instructions in my article SSR Astro With Headless Craft CMS.
Then come back here.
Fetch / use cache toggle
With this Astro + headless Craft implementation, the site can fetch data or rely on the static cache. Being able to toggle this becomes useful in Build (below) and Deploy (below).
Add the variable FETCH_CMS_CONTENT to packages/app/.env.example:
# …
FETCH_CMS_CONTENT= # `true` for true, any other value for falseand to packages/app/.env where, for now, we will set it to true
# …
FETCH_CMS_CONTENT=trueIf you’re using TypeScript, add the variable’s type information to src/env.d.ts:
// …
interface ImportMetaEnv {
// …
readonly FETCH_CMS_CONTENT: string;
}Scaffold content collections
Each page that has CMS content will have an associated Astro content collection.
Scaffold that page now.
import { defineCollection } from "astro:content";
export const collections = {
// @TODO collections will be defined here
};Add pages
Pages not tied to a Craft section
Templates
These pages are what Craft CMS calls “routes”: pages with arbitrary content, potentially pulled from the CMS, as opposed to pages tied to the Craft data model.
Create Astro pages not tied to a particular Craft section at packages/app/src/pages/<uri>.astro, replacing <uri> with the desired URI. (Nesting works - <uri> could be my/cool/page.)
If the page doesn’t have content from Craft, build out its template and move on to the next.
If the page does have content from Craft, set up its template as follows.
The file packages/app/src/config/replace-me-with-the-uri.ts is covered next.
The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data.
This uses, in order of appearance
- Astro’s
astro:content>getEntry() - Zod’s
infer - Astro’s
import.meta
---
import { getEntry } from 'astro:content';
import { z } from 'zod';
import config from '@config/replace-me-with-the-uri';
import fetchAPI from '@lib/craft-cms/fetch-api';
const { query, schema } = config;
let data: z.infer<typeof schema>;
if (import.meta.env.FETCH_CMS_CONTENT === 'true') {
data = await fetchAPI({ query, schema });
} else {
const { data: cachedData } = await getEntry('replace-me-with-the-uri', 'cms-cache');
data = cachedData;
}
---
{/*
The GraphQL CMS query response's data is now available
to the template as `data`.
*/ }Configs
For each route page in app/src/pages, create a page config file in app/src/config.
The filename can be anything. I recommend using replace-me-with-the-uri.ts, where replace-me-with-the-uri is the same as the app/src/pages file’s name.
Replace the query with your query, and update the schema to match.
The file packages/app/src/lib/craft-cms/types.ts doesn’t exist yet. It will come, in Define config types (below).
This uses, in order of appearance
import { z } from 'zod';
import type { RouteConfig } from '@lib/craft-cms/types';
const query = `{
${/* … */}
}`;
const schema = z.object({
// …
});
export default { query, schema } satisfies RouteConfig<typeof schema>;Content collections
In packages/app/src/content/config.ts, define a collection for the page:
- Import the page’s config file.
- Add an entry to the
collectionsobject.- The string must match the config file’s name.
- The value is
defineCollection()withtype: 'data'andschema: <the imported config>.schema(replace<the imported config>with the imported config)
Danger
You can’t use dynamic keys in the collections object. They have to be hard coded. Be sure to keep them in sync with the config file name!
This uses Astro’s astro:content > defineCollection().
import { defineCollection } from "astro:content";
// …
import replaceMeWithTheUriConfig from "@config/replace-me-with-the-uri";
export const collections = {
// …
"replace-me-with-the-uri": defineCollection({
type: "data",
schema: replaceMeWithTheUriConfig.schema,
}),
};Singles
Singles templates
For the Craft homepage section, create an Astro page at app/src/pages/index.astro.
For other Craft single sections, create Astro pages at app/src/pages/<uri>.astro, replacing <uri> with the Craft CMS section’s URI (Craft CMS control panel > Settings > Sections > the section > Site Settings > URI).
The file packages/app/src/config/replace-me-with-the-uri.ts is covered next.
The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data.
This uses, in order of appearance
- Astro’s
astro:content>getEntry(). - Zod’s
infer - Astro’s
import.meta
---
/**
* Page for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import { getEntry } from 'astro:content';
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
import config from '@config/replace-me-with-the-uri';
import url from '@lib/craft-cms/url';
const { query, schema } = config;
let data: z.infer<typeof schema>;
if (import.meta.env.FETCH_CMS_CONTENT === 'true') {
data = await fetchAPI({ query, schema });
} else {
const { data: cachedData } = await getEntry('index', 'cms-cache');
data = cachedData;
}
if (data.entries.length === 0) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{
/*
The Craft entry's data is available to the template
as `entry`. The full response data is available as
`data`.
*/
}Singles configs
For each single section page in app/src/pages, create a page config file in app/src/config.
The filename can be anything. I recommend using replace-me-with-the-uri.ts, where replace-me-with-the-uri is the same as the app/src/pages file’s name.
Replace the query with your query, and update the schema to match.
The file packages/app/src/lib/craft-cms/types.ts doesn’t exist yet. It will come, in Define config types (below).
This uses, in order of appearance
import { z } from 'zod';
import type { SingleConfig } from '@lib/craft-cms/types';
const query = `{
entries (section: "replaceMeWithTheSectionHandle") {
${/* optionally, fields here */}
}
${/* optionally, more queries here */}
}`;
const schema = z.object({
entries: z
.object({ /* … */ })
.array(),
// …
});
export default { query, schema } satisfies SingleConfig<typeof schema>;Singles content collection
In packages/app/src/content/config.ts, define a collection for the page:
- Import the page’s config file.
- Add an entry to the
collectionsobject.- The string must match the config file’s name.
- The value is
defineCollection()withtype: 'data'andschema: <the imported config>.schema(replace<the imported config>with the imported config)
Danger
You can’t use dynamic keys in the collections object. They have to be hard coded. Be sure to keep them in sync with the config file name!
This uses Astro’s astro:content > defineCollection().
// …
import replaceMeWithTheUriConfig from "@config/replace-me-with-the-uri";
export const collections = {
// …
"replace-me-with-the-uri": defineCollection({
type: "data",
schema: replaceMeWithTheUriConfig.schema,
}),
};Channels and structures
Channels and structures templates
Note
This model assumes a simple entry URI format (Craft CMS control panel > Settings > Sections > the section > Site Settings > Entry URI Format) of the pattern uri-prefix/{slug}. It can be adapted to more complex formats. Read more in Astro’s routing’s rest parameters documentation.
Create the Astro pages for Craft CMS channels and structures at app/src/pages/<uri-prefix>/[...slug].astro, replacing <uri-prefix> with whatever comes before {slug} in the Craft CMS section’s URI. The file name [...slug].astro tells Astro to treat this as a dynamic route. slug becomes available in the page’s Astro.params.
The page’s CMS query’s response —fetched on demand in dev mode, and read from static JSON in production— is transformed to a shape Astro’s SSG mode can match the requested slug against.
The file packages/app/src/config/replace-me-with-the-uri-prefix.ts is covered next.
The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data.
This uses, in order of appearance
- Astro’s
getStaticPathsfunction - Astro’s
GetStaticPathstype - Astro’s
Props - TypeScript generics
- Zod’s
infer - Zod’s
shape - Zod’s
element - Astro’s
import.meta - Astro’s
astro:content>getEntry() - TypeScript’s
satisfies - Astro’s
props
---
/**
* Pages for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import type { GetStaticPaths } from 'astro';
import { getEntry } from 'astro:content';
import { z } from 'zod';
import config from '@config/replace-me-with-the-uri-prefix';
import fetchAPI from '@lib/craft-cms/fetch-api';
import staticPaths from '@lib/craft-cms/static-paths';
type Data = z.infer<typeof config.schema>;
/**
* Astro.props (below) is type `Props`
* https://docs.astro.build/en/guides/typescript/#component-props
*/
interface Props {
data: Data;
entry: z.infer<typeof config.schema.shape.entries.element>;
}
export const getStaticPaths = (async () => {
const { query, schema, uriPrefix } = config;
let data: z.infer<typeof schema>;
if (import.meta.env.FETCH_CMS_CONTENT === 'true') {
data = await fetchAPI({ query, schema });
} else {
const { data: cachedData } = await getEntry(
'replace-me-with-the-uri-prefix',
'cms-cache',
);
data = cachedData;
}
return await staticPaths<Data>({ data, uriPrefix });
}) satisfies GetStaticPaths;
const { data, entry } = Astro.props;
---
{
/*
The Craft entry's data is available to the template
as `entry`. The full response data is available as
`data`.
*/
}Channels and structures configs
For each channel or structure section page in app/src/pages, create a page config file in app/src/config.
The filename can be anything. I recommend using replace-me-with-the-uri-prefix.ts, where replace-me-with-the-uri-prefix is the same as the app/src/pages file’s name.
Replace the query with your query, and update schema to match.
Replace the uriPrefix with the Craft section’s entry URI format’s prefix.
The file packages/app/src/lib/craft-cms/types.ts doesn’t exist yet. It will come, in Define config types (below).
This uses, in order of appearance
import { z } from 'zod';
import type { /* either ChannelConfig or StructureConfig */ } from '@lib/craft-cms/types';
const query = `{
entries (section: "replaceMeWithTheSectionHandle") {
uri
${/* optionally, more fields */}
}
${/* optionally, more queries here */}
}`;
const schema = z.object({
entries: z
.object({
uri: z.string(),
// …
})
.array(),
// …
});
export default {
query,
schema,
uriPrefix: 'replace-me-with-the-uri-prefix',
} satisfies /* replace me with the imported type */<typeof schema>;The last line of that will read either
} satisfies ChannelConfig<typeof schema>;or
} satisfies StructureConfig<typeof schema>;Channels and structures content collections
In packages/app/src/content/config.ts, define a collection for the page:
- Import the page’s config file.
- Add an entry to the
collectionsobject.- The string must match the config file’s name.
- The value is
defineCollection()withtype: 'data'andschema: <the imported config>.schema(replace<the imported config>with the imported config)
Danger
You can’t use dynamic keys in the collections object. They have to be hard coded. Be sure to keep them in sync with the config file name!
This uses Astro’s astro:content > defineCollection().
// …
import replaceMeWithTheUriPrefixConfig from "@config/replace-me-with-the-uri-prefix";
export const collections = {
// …
"replace-me-with-the-uri-prefix": defineCollection({
type: "data",
schema: replaceMeWithTheUriConfig.schema,
}),
};Define config types
Route pages with CMS content, Craft single pages, Craft channel pages, and Craft structure pages have distinct data requirements.
- “Route” pages (pages not tied to a Craft section) with CMS content need a GraphQL query.
- Craft single pages require a GraphQL query with a Craft entries query under the key “entries”.
- Craft channel pages and Craft structure pages require a GraphQL query with a Craft entries query under the key “entries”, with the
urifield.
TypeScript can help ensure that the configurations are set up correctly.
Follow the write-up in SSR Astro With Headless Craft CMS.
Normalize URLs
I find uri a useful field to include in queries. Prefix it with / and you can use it as the href for front end links.
Because of the homepage gotcha, I use a URL helper.
Follow the write-up in SSR Astro With Headless Craft CMS.
Fetch data
The above pages all rely on packages/app/src/lib/craft-cms/fetch-api.ts.
My implementation for SSG Astro with headless Craft is identical to what I do with SSR Astro. Follow the write-up in SSR Astro With Headless Craft CMS.
Get static paths
The crucial difference between SSG Astro with headless Craft and SSR Astro with headless Craft is in the handling of dynamic routes’ —that is, in Astro pages for Craft channel and structure sections’— data.
My implementation for SSG Astro with headless Craft is identical to what I do with SSG Astro when fetching content at build time. Follow the write-up in SSG Astro with Headless Craft CMS Content Fetched At Build Time.
Cache content
The production build pulls data from static Astro content collections.
Write a script to handle the caching, and then call it via a package script.
Because I’m using TypeScript, I run the script file with Bun — Bun can run TypeScript files as scripts directly. If you don’t use TypeScript, rewrite cache in some other language.
The script loops over the files in ./packages/app/src/config, fetches the data for each file’s query, and writes the response data to a JSON file cms-cache.json in the directory ./packages/app/src/content/<config file name>. The JSON output also defines the collection’s $schema value, to support Astro collections’ IntelliSense and type-checking support.
TypeScript users:
-
If you don’t have Bun, install it following the instructions at https://bun.sh/.
-
Add
@types/nodeandbun-typesas devDependencies. -
Add
bun-typesto your tsconfig’scompilerOptions../packages/app/tsconfig.jsonjson"types": ["bun-types"],
Be careful with your data
If your query responses included sensitive data, you’ll want to gitignore the directories in packages/app/src/content by adding src/content/*/ to packages/app/.gitignore.
This uses, in order of appearance,
- Node’s
path.join() - Bun’s
$shell - Bun’s
Glob - Bun’s
import.meta.dir - Bun’s
write()
import { $, Glob } from "bun";
import path from "node:path";
import fetchAPI from "@lib/craft-cms/fetch-api";
await cache();
/**
* Fetch and cache Craft CMS data.
*/
async function cache() {
const pattern = path.join(import.meta.dirname, "../src/config/**/*.ts");
const glob = new Glob(pattern);
for await (const file of glob.scan(".")) {
const basename = (await $`basename ${file}`.text()).trim();
const indentation = " ";
console.log(`src/config/${basename}`);
console.log(`${indentation}Processing`);
const basenameWithoutExtension = basename.replace(/\.ts$/, "");
const { query, schema } = await import(file).then((m) => m.default);
const data = await fetchAPI({ query, schema });
if (data === undefined) {
console.warn(`${indentation}No data returned`);
continue;
}
await Bun.write(
`src/content/${basenameWithoutExtension}/cms-cache.json`,
JSON.stringify({
$schema: `../../../.astro/collections/${basenameWithoutExtension}.schema.json`,
...data,
}),
);
console.log(`${indentation}Data fetched and cached\n`);
}
}Add a data caching package script in the app package.
"scripts": {
"cache": "bun bin/cache.ts",If you don’t use TypeScript, rewrite cache in some other language and update the package script. For example for a Node.js make this change:
"scripts": {
"cache": "bun bin/cache.ts",
"cache": "node bin/cache.js",Now you can cache data: from packages/app, run the cache script followed by the build script.
cd packages/app
# Bun
bun run cache
# NPM
npm run cache
# pnpm
pnpm run cache
# Yarn
yarn run cacheIf you have top-level shorthand package scripts as written up in Monorepo Setup For Headless Craft CMS, you could also run <package manager> run app cache from the project root or from the ./packages directory.
Build
From packages/app, run the build script. Note that by default this
-
does fetch CMS content
You can opt out of fetching CMS content at build time and instead rely on the cached data. Read on for details.
-
does not cache the response
For that, run the
cachescript set up in Cache content (above).
cd packages/app
# Bun
bun run build
# NPM
npm run build
# pnpm
pnpm run build
# Yarn
yarn run buildIf you have top-level shorthand package scripts as written up in Monorepo Setup For Headless Craft CMS, you could also run <package manager> run app build from the project root or from ./packages.
Optionally rely on the cache
An exciting thing about caching CMS content in the app, rather than fetching the content at build time, is that you can build without being able to connect to the CMS. This means for example that you can push template changes to an ECMAScript-centric continuous deployment service (Netlify, Vercel, et al.) and rebuild the site using the cached data.
If you plan on building with cached content, you’ll need to run the cache script at least once to seed the data.
To build using cached data, either
-
do not define
FETCH_CMS_CONTENTfrom.env./packages/app/.envyaml# … FETCH_CMS_CONTENT=true -
in
.env, setFETCH_CMS_CONTENT=./packages/app/.envyaml# … FETCH_CMS_CONTENT=true FETCH_CMS_CONTENT= -
rather than running
bun run build, runshellFETCH_CMS_CONTENT= bun run build(this will take precedence any
FETCH_CMS_CONTENTdeclaration in.env).
Deploy
You have three options: build locally, build remotely with CMS content fetched at build time, or build remote with cached CMS content.
To build locally
- Run the
buildscript. - Upload
packages/app/dist.
To build with a remote continuous build/deploy service using cached CMS content:
- Configure the continuous build/deploy:
- Build with the app package’s
buildscript. - Deploy from the app package’s
distdirectory. - Leave the environment variable
FETCH_CMS_CONTENTundeclared.
- Build with the app package’s
- Locally, run the
cachescript and uploadpackages/app/src/content. - Trigger your CD build/deploy.
To build with a remote continuous deployment service, fetching CMS content at build time
- Configure the continuous build/deploy:
- Build with the app package’s
buildscript. - Deploy from the app package’s
distdirectory. - Set the environment variable
FETCH_CMS_CONTENT=true, or do some fanciness of your own design to run thecachescript and then defineFETCH_CMS_CONTENTbased on whether the cached content has changed (eleventy-fetchmay be relevant prior art for fetching and caching data on a build server).
- Build with the app package’s
- Trigger your CD build/deploy.
And there you have it! A static Astro site with content managed in Craft CMS, and the option of building from cached data in environments which don’t have access to the CMS!
On the web
Reposts
Likes
Also In This Series
-
-
SSR Astro With Headless Craft CMS

Craft’s great data modelling and admin experience, with a server-rendered Astro front end.
-
SSG Astro with Headless Craft CMS Content Fetched At Build Time

Astro on the front, Craft on the back. And Craft can be local-only!
Articles You Might Enjoy
-
Accessible CSS-Only Light/Dark Toggles (with JS persistence as progressive enhancement)

CSS’s
:has()can obviate JS -
Hometown: A Dynamic, Highly Configurable Git-Focused Zsh Theme

A fast zsh prompt that packs in a lot of Git status info
-
From Stimulus to web components

Stimulus experience translates to native custom elements. What does it take to make a custom element with Stimulus features?







