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

Astro on the front, Craft on the back. Craft can be local only, and the build environment doesn't have to connect to the CMS.
Color photo of a space shuttle in space, showing the top of the shuttle and, in the background, Earth
Space Shuttle. NASA Lyndon B. Johnson Space Center Houston, Texas. Dragan. CC BY 2.0. Source: Wikimedia Commons.

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:

./packages/app/.env.example
yaml
# …
FETCH_CMS_CONTENT= # `true` for true, any other value for false

and to packages/app/.env where, for now, we will set it to true

./packages/app/.env
yaml
# …
FETCH_CMS_CONTENT=true

If you’re using TypeScript, add the variable’s type information to src/env.d.ts:

./packages/app/src/env.d.ts
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.

./packages/app/src/content/config.ts
ts
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

./packages/app/src/pages/replace-me-with-the-uri.astro
ts
---
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

./packages/app/src/config/replace-me-with-the-uri.ts
ts
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:

  1. Import the page’s config file.
  2. Add an entry to the collections object.
    • The string must match the config file’s name.
    • The value is defineCollection() with type: 'data' and schema: <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().

./packages/app/src/content/config.ts
ts
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

./packages/app/src/pages/replace-me-with-the-uri.astro
ts
---
/**
 * 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

./packages/app/src/config/sections/replaceMeWithTheSectionHandle.ts
ts
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:

  1. Import the page’s config file.
  2. Add an entry to the collections object.
    • The string must match the config file’s name.
    • The value is defineCollection() with type: 'data' and schema: <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().

./packages/app/src/content/config.ts
ts
// …
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

./packages/app/src/pages/replace-me-with-the-uri-prefix.astro
ts
---
/**
 * 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

./packages/app/src/config/sections/replace-me-with-the-uri-prefix.ts
ts
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

ts
} satisfies ChannelConfig<typeof schema>;

or

ts
} satisfies StructureConfig<typeof schema>;

Channels and structures content collections

In packages/app/src/content/config.ts, define a collection for the page:

  1. Import the page’s config file.
  2. Add an entry to the collections object.
    • The string must match the config file’s name.
    • The value is defineCollection() with type: 'data' and schema: <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().

./packages/app/src/content/config.ts
ts
// …
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 uri field.

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/node and bun-types as devDependencies.

  • Add bun-types to your tsconfig’s compilerOptions.

    ./packages/app/tsconfig.json
    json
        "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,

./packages/app/bin/cache.ts
ts
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.

./packages/app/package.json
json
  "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:

./packages/app/package.json
json
  "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.

shell
cd packages/app

# Bun
bun run cache

# NPM
npm run cache

# pnpm
pnpm run cache

# Yarn
yarn run cache

If 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 cache script set up in Cache content (above).

shell
cd packages/app

# Bun
bun run build

# NPM
npm run build

# pnpm
pnpm run build

# Yarn
yarn run build

If 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_CONTENT from .env

    ./packages/app/.env
    yaml
    # …
    FETCH_CMS_CONTENT=true
  • in .env, set FETCH_CMS_CONTENT=

    ./packages/app/.env
    yaml
    # …
    FETCH_CMS_CONTENT=true
    FETCH_CMS_CONTENT=
  • rather than running bun run build, run

    shell
    FETCH_CMS_CONTENT= bun run build

    (this will take precedence any FETCH_CMS_CONTENT declaration 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

  1. Run the build script.
  2. Upload packages/app/dist.

To build with a remote continuous build/deploy service using cached CMS content:

  1. Configure the continuous build/deploy:
    • Build with the app package’s build script.
    • Deploy from the app package’s dist directory.
    • Leave the environment variable FETCH_CMS_CONTENT undeclared.
  2. Locally, run the cache script and upload packages/app/src/content.
  3. Trigger your CD build/deploy.

To build with a remote continuous deployment service, fetching CMS content at build time

  1. Configure the continuous build/deploy:
    • Build with the app package’s build script.
    • Deploy from the app package’s dist directory.
    • Set the environment variable FETCH_CMS_CONTENT=true, or do some fanciness of your own design to run the cache script and then define FETCH_CMS_CONTENT based on whether the cached content has changed (eleventy-fetch may be relevant prior art for fetching and caching data on a build server).
  2. 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

Links

Reposts

Also In This Series

Articles You Might Enjoy

Or Go To All Articles