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!
Color photo of an antique toy commuter rocketship with windows down the side like an airplan. It is painted metalic silver and blue with red fins and nose cone, and is labelled Rocket Express. In the background is its box.
Litho tin toy MONORAIL ROCKET-SHIP - LINEMAR, Rocket Express. Alf van Beem. CC0. Source: Wikimedia Commons.

This guide builds an Astro site, in prerendered (aka SSG) mode, with content modelled and managed in Craft CMS and fetched from the CMS with GraphQL.

🆒 The exciting thing about putting a static front end on a Craft site is that the CMS can be local-only!

Read More

This article is the third in a series. 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 fourth part covers SSG Astro with the option of using cached data for the build.

Read the code

Prefer to study code? This article’s project is in the olets/astro-craftcms repo’s ssg branch.

Interested in the diff between SSG Astro + Craft CMS and SSR Astro + Craft CMS? There’s a pull request for that.

This guide is narrated sequentially: some steps are explained in or require that you have completed the preceding ones.

I’ve used TypeScript, but you can use JavaScript; if you choose TypeScript and aren’t already familiar with it, there’s a short official TypeScript for JavaScript Programmers.

Here’s how it works:

  • The project is a monorepo with packages for the Astro front end and the Craft CMS backend.

  • The front end package’s Astro page structure mirrors the back end package’s Craft CMS data model: the front end’s URIs match the back end’s sections’ entry URI formats.

  • Front end pages which include Craft content fetch the response for a GraphQL query.

  • Pages for Craft singles, and pages that use arbitrary CMS content (the equivalent of a subset of headed Craft’s routes), use static Astro routes.

  • Pages for Craft CMS channels and structures use dynamic Astro routes.

  • In dev mode, Craft content is fetched on browser page load. CMS changes are immediately reflected on reload.

  • Craft content is fetched during the build process, guaranteeing that the static site uses the latest content as of its build time.

Note

How to host and deploy Craft CMS is beyond the scope of this guide. Read Deploy, below, for resources.

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 the API URL to the app as an environment variable

Follow the env var typing and API env var creation instructions in my article SSR Astro With Headless Craft CMS.

Then come back 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.

Notes:

  • The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data.

  • The fetchAPI function will take a GraphQL query and return either the response’s data, typed according to the schema, or undefined. The response’s data is checked against the schema, helping with maintenance: if the query and the schema fall out of sync, parsing with Zod will error.

./packages/app/src/pages/replace-me-with-the-uri.astro
ts
---
import config from '@config/routes/replace-me-with-the-uri';
import fetchAPI from '@lib/craft-cms/fetch-api';

const { query, schema } = config;

const data = await fetchAPI({ query, schema });
---

{/* The response's data is now available to the template as `data`. */ }

Configs

Now create this page’s config file in app/src/config/routes. I use a separate file for two reasons:

  1. It keeps the .astro page file’s front matter concise.

  2. It gives us the opportunity to surface in the file tree the distinction between sections and routes.

The filename can be anything. I recommend using routes/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).

./packages/app/src/config/routes/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>;

Building your GraphQL queries

New to GraphQL? Learn the fundamentals in GraphQL’s Queries and Mutations
docs
. Craft CMS provides a GraphiQL in-browser GraphQL IDE. Go to the control panel > GraphQL > GraphiQL. Toggle the “Explorer” to see all available fields and arguments. The ▶️ button will run the query.

Craft’s GraphQL schema is documented here.

Singles

Templates

Create the Astro page for the Craft CMS homepage at app/src/pages/index.astro.

Create the Astro pages for other Craft CMS singles 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/sections/replaceMeWithTheSectionHandle.ts is covered next.

The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data.

./packages/app/src/pages/replace-me-with-the-uri.astro
ts
---
/**
 * Page for "REPLACE ME WITH THE SECTION NAME" Craft section
 */

import config from '@config/sections/replaceMeWithTheSectionHandle';
import fetchAPI from '@lib/craft-cms/fetch-api';

const { query, schema } = config;

const data = await fetchAPI({ query, schema });

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`.
  */
}

Configs

For each single section page, create a config file in packages/app/src/config/sections. The page config file can have any name, but I recommend using the Craft section’s name or handle — in this example, packages/app/src/config/sections/replaceMeWithTheSectionHandle.ts.

Replace the query with your query, and update the schema to match. The query should

  • return the entry data in entries

  • limit entries by section handle, specified in the query’s section argument

The file packages/app/src/lib/craft-cms/types.ts doesn’t exist yet. It will come, in Define config types (below).

./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>;

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.

These pages are coded similarly to the above singles’ pattern, but the URI is dynamic.

The file packages/app/src/config/sections/replaceMeWithTheSectionHandle.ts is covered next. The file packages/app/src/lib/craft-cms/fetch-api.ts is covered below, in Fetch data. The file packages/app/src/lib/craft-cms/static-paths.ts is covered below, in Get static paths.

packages/app/src/lib/craft-cms/static-paths.ts’s default export will be a function which returns an array compatible with Astro’s getStaticPaths.

If the response’s entries is empty, it’s a front end 404.

This is how it works:

  1. Astro’s Props interface is defined according to the shape of entries in the section’s schema

  2. Data is fetched from the CMS

  3. The fetched data is transformed into a format static dynamic Astro page can use

  4. When the site is built, a page is generated for each item in that transformed data. params.slug determines the page’s URI, and props’s entry is available in the page template.

This uses, in order of appearance

./packages/app/src/pages/replace-me-with-the-uri.astro
ts
---
/**
 * Pages for "REPLACE ME WITH THE SECTION NAME" Craft section
 */

import { z } from 'zod';
import type { GetStaticPaths } from 'astro';
import config from '@config/sections/replaceMeWithTheSectionHandle';
import fetchAPI from '@lib/craft-cms/fetch-api';
import staticPaths from '@lib/craft-cms/static-paths';

/**
 * Astro.props (below) is type `Props`
 * https://docs.astro.build/en/guides/typescript/#component-props
 */
interface Props {
  data: z.infer<typeof config.schema>;
  entry: z.infer<typeof config.schema.shape.entries.element>;
}

export const getStaticPaths = (async () => {
  const { query, schema, uriPrefix } = config;

  const data = await fetchAPI({ query, schema });

  return await staticPaths<typeof 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`.
  */
}

Configs

For each channel section page and structure section page, create a config file in packages/app/src/config/sections. The page config file can have any name, but I recommend using the Craft section’s name or handle.

Replace the query with your query, and update the schema to match. The query should

  • return the entry data in entries

  • limit entries by section handle, specified in the query’s section argument

  • include the uri field in entries. Its value should be the section’s uri prefix followed by /${slug}.

The file packages/app/src/lib/craft-cms/types.ts doesn’t exist yet. It will come, in Define config types (below).

./packages/app/src/config/sections/replaceMeWithTheSectionHandle.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 */}
  }
}`;

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>;

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.

Create the file packages/app/src/lib/craft-cms/types.ts, used by Routes with CMS content, Singles, and Channels and structures.

This uses, in order of appearance

./packages/app/src/lib/craft-cms/types.ts
ts
import { z } from "zod";

const channelSchema = z.object({
  entries: z
    .object({
      uri: z.string(),
    })
    .array(),
});

const routeSchema = z.object({});

const singleSchema = z.object({
  entries: z.object({}).array(),
});

interface Config<T> {
  query: string;
  schema: T;
}

export interface ChannelConfig<T extends typeof channelSchema>
  extends Config<T> {
  uriPrefix: string;
}

export interface RouteConfig<T extends typeof routeSchema> extends Config<T> {}

export interface SingleConfig<T extends typeof singleSchema>
  extends Config<T> {}

export interface StructureConfig<T extends typeof channelSchema>
  extends ChannelConfig<T> {}

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.

Craft CMS’s GraphQL has url and uri fields. url is absolute (e.g. http://my-project.ddev.site/my-page). uri is what you’d expect, except for the homepage single section, which has the uri __home__.

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.

Some of that work is done in packages/app/src/lib/craft-cms/static-paths.ts.

It uses, in order of appearance

./packages/app/src/lib/craft-cms/static-paths.ts
ts
import type { GetStaticPathsResult } from "astro";

interface Data {
  entries: {
    uri: string;
  }[];
}

/**
 * Builds Astro static paths from query response data.
 *
 * @template T the response data's type
 * @augments T Data
 * @param data
 * @param [uriPrefix] optional. The URI prefix to trim from entry URIs to determine the slug.
 * @returns
 */
export default async function <T extends Data>({
  data,
  uriPrefix,
}: {
  data: T;
  uriPrefix?: string;
}): Promise<GetStaticPathsResult> {
  const { entries } = data;

  return entries.map((entry) => {
    let slug = entry.uri;

    if (slug !== undefined && uriPrefix !== undefined) {
      slug = slug.replace(new RegExp(`^${uriPrefix}/`), "");
    }

    return {
      params: { slug },
      props: { data, entry },
    };
  });
}

Build

From packages/app, run the build script: <your package manager> run build, replacing <your package manager> with your package manager (e.g. bun run build).

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 app build from the project root or from ./packages.

Deploy

An exciting thing about putting a static front end on a Craft site is that the CMS can be local-only.

If your CMS is local-only

  1. Ensure that packages/app/.gitignore does not include dist.

  2. Run the build script to build the site.

  3. Upload app/dist

If your CMS is available remotely, and you want to build with data from the remote CMS environment,

  1. Ensure that packages/app/.gitignore does include dist.

  2. Configure your continuous deployment:

    • set the CRAFT_CMS_GRAPHQL_URL env var to the remote Craft GraphQL endpoint’s URL

    • build with the build script as described in Build, above

    • serve packages/app/dist

  3. Trigger your build (e.g. by pushing to a remote repo connected to a continuous delivery service).

 

And there you have it! A static Astro site with content managed in Craft CMS!

On the web

Links

Also In This Series

Articles You Might Enjoy

Or Go To All Articles