SSR Astro With Headless Craft CMS

Last Updated

Craft’s great data modelling and admin experience, with a server-rendered Astro front end.
1970s painting of a rocket ship flying over Earth (the angle suggests it may be orbiting)
HYDROGEN FUELED TRANSPORT - HYDROGEN PLANT AIRPORT - LAUNCH ROCKET GLIDE TRANSPORT - LANDING OF ROCKET GLIDE TRANSPORT - ROCKET GLIDE TRANSPORT. National Archives and Records Administration. Public Domain. Source: Wikimedia Commons.

This guide builds an Astro site, in on-demand rendered (aka SSR) mode, with content modelled and managed in Craft CMS and fetched from the CMS with GraphQL.

Read More

This article is the second in a series. The first part covers setting up a monorepo for headless Craft CMS. The next will cover SSG Astro with headless Craft.

Read the code

Prefer reading code over exposition? You can browse my demo Craft + SSR Astro project’s files. (If you do, consider starring the repo — it’s the only way I have of tracking repo viewers.)

Contents

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

The bulk of the code is left to speak for itself. Take the time to read it. When I rely on things I estimate beginners may not know, links are provided to the official documentation. 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’s URI settings: 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.

  • The fetched data is validated and typed.

  • 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.

Note

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

Set up the monorepo and its CMS package

Follow my guide Monorepo Setup for Headless Craft CMS’s instructions for roughing out the monorepo and building the CMS package. When you get to the app package instructions, come back here.

Set up the Astro package

  1. Ensure that you are on a version of Node.js compatible with Astro. See the official Prerequisites docs. If you use a Node.js version manager, add its config file to the project root. For example, I’m using asdf, so I create a .tool-versions file in the project root, and specify a nodejs version.

  2. cd to the packages directory.

  3. Run <your package manager> create astro@latest, replacing <your package manager> with your package manager (e.g. bun create astro@latest).

    Follow the CLI prompts, with these answers:

    • Where should we create your new project? app
    • Initialize a new git repository? no
    • This tutorial assumes choosing TypeScript, not JavaScript. You can use either.
  4. In packages/app/package.json give your front end package the project root package.json’s "name" with the suffix -app.

    ./packages/app/package.json
    json
    json
    {
    "name": "<replace with project name>-app",
    json
    {
    "name": "<replace with project name>-app",
  5. Add a path alias. In packages/app/tsconfig.json (for TS users) or packages/app/jsconfig.json (for JS users), point @ to src:

    ./packages/app/(jsconfig|tsconfig).json
    json
    json
    {
    "extends": "astro/tsconfigs/strict",
    "compilerOptions": {
    "baseUrl": ".",
    "paths": {
    "@*": ["src/*"],
    },
    }
    }
    json
    {
    "extends": "astro/tsconfigs/strict",
    "compilerOptions": {
    "baseUrl": ".",
    "paths": {
    "@*": ["src/*"],
    },
    }
    }

Add the API URL to the app as an environment variable

Create a .env.example file in the app directory (if one does not already exist) and add this variable:

./packages/app/.env
yaml
yaml
CRAFT_CMS_GRAPHQL_URL=
yaml
CRAFT_CMS_GRAPHQL_URL=

Create a .env file app directory (if one does not already exist) and add the same variable, this time set to your Craft CMS GraphQL API endpoint’s URL. (Replace <replace with project name> with the project name.)

./packages/app/.env.example
yaml
yaml
CRAFT_CMS_GRAPHQL_URL=http://<replace with project name>.ddev.site/api
yaml
CRAFT_CMS_GRAPHQL_URL=http://<replace with project name>.ddev.site/api

Add .env to the app’s .gitignore file, if it isn’t already there.

./packages/app/.gitignore
yaml
yaml
.env
yaml
.env

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

./packages/app/src/env.d.ts
ts
ts
// …
interface ImportMetaEnv {
readonly CRAFT_CMS_GRAPHQL_URL: string;
}
ts
// …
interface ImportMetaEnv {
readonly CRAFT_CMS_GRAPHQL_URL: string;
}

Configure the Astro rendering mode

Use Astro’s “on-demand” rendering mode, aka SSR mode.

./packages/app/astro.config.mjs
ts
ts
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
// https://docs.astro.build/en/guides/server-side-rendering/#configure-server-or-hybrid
output: 'server',
});
ts
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
// https://docs.astro.build/en/guides/server-side-rendering/#configure-server-or-hybrid
output: 'server',
});

Add pages

Fetched data is validated with zod. Add it as a dependency:

shell
shell
<your package manager> add zod
shell
<your package manager> add zod

Routes

By “routes” I mean what Craft CMS calls routes: pages with arbitrary content, as opposed to pages tied to the Craft data model.

Create the Astro pages for routes at app/src/pages/<uri>.astro, replacing <uri> with the desired slug.

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

We have not yet created the file src/lib/craft-cms/fetch-api.ts, or its default function. That will come. The 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 validated against the schema, helping with maintenance: if the query and the schema fall out of sync

This uses, in order of appearance

./packages/app/src/src/pages/replace-me-with-the-uri.astro
ts
ts
---
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const query = `{
entries {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array(),
});
const data = await fetchAPI({
query,
schema,
});
---
<ul>
{data?.entries?.map((entry) => (
<li>
{entry.title}
</li>
))}
</ul>
ts
---
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const query = `{
entries {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array(),
});
const data = await fetchAPI({
query,
schema,
});
---
<ul>
{data?.entries?.map((entry) => (
<li>
{entry.title}
</li>
))}
</ul>

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

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).

These pages are coded similarly to the above routes’ pattern, but the query is narrowed to the section’s URI.

Replace the query with your query, specifying the section’s URI in the uri argument.

Where the example route page ended with entries = data?.entries, singles’ pages have to narrow down to one entry.

Using the Craft entry’s URI for the file name is not required but recommended, for maintainability and intelligibility.

This uses, in order of appearance

./packages/app/src/src/pages/replace-me-with-the-uri.astro
ts
ts
---
/**
* Page for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const query = `{
entries(section: "homepage") {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array()
.length(1),
});
const data = await fetchAPI({
query,
schema,
});
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry.title}
ts
---
/**
* Page for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const query = `{
entries(section: "homepage") {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array()
.length(1),
});
const data = await fetchAPI({
query,
schema,
});
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry.title}

Channels and structures

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 other Craft CMS singles 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. Replace the query with your query.

This uses, in order of appearance

./packages/app/src/src/pages/replace-me-with-the-section-uri-prefix/[...slug].astro
ts
ts
---
/**
* Page for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const { slug = '' } = Astro.params;
const query = `{
entries(uri: "example-structure/${slug}") {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array()
.nonempty(),
});
const data = await fetchAPI({
query,
schema,
});
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry.title}
ts
---
/**
* Page for "REPLACE ME WITH THE SECTION NAME" Craft section
*/
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
const { slug = '' } = Astro.params;
const query = `{
entries(uri: "example-structure/${slug}") {
title
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
})
.array()
.nonempty(),
});
const data = await fetchAPI({
query,
schema,
});
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry.title}

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:

In the app directory, I create the file src/lib/craft-cms/url.ts. The default export is a function which converts a Craft CMS GraphQL response entry’s uri to the URI we’ll use on the front end.

This uses:

./packages/app/src/lib/craft-cms/url.ts
ts
ts
/**
* Format a Craft entry's URI for use a local front end URL.
*
* @param uri
* @returns
*/
export default function url(uri?: string): string | undefined {
if (uri === undefined) {
return undefined;
}
if (uri === '__home__') {
return '/';
}
return `/${uri.replace(/^\//, '')}`;
}
ts
/**
* Format a Craft entry's URI for use a local front end URL.
*
* @param uri
* @returns
*/
export default function url(uri?: string): string | undefined {
if (uri === undefined) {
return undefined;
}
if (uri === '__home__') {
return '/';
}
return `/${uri.replace(/^\//, '')}`;
}

That unlocks things like this route example (compare to the version in Routes, above):

./packages/app/src/src/pages/example-route.astro
ts
ts
---
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
import url from '@lib/craft-cms/url.ts';
const query = `{
entries {
title
uri
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
uri: z.string(),
})
.array(),
});
const data = await fetchAPI({
query,
schema,
});
---
<ul>
{
data?.entries.map((entry) => (
<li>
<a href={url(entry.uri)}>{entry.title}</a>
</li>
))
}
</ul>
ts
---
import { z } from 'zod';
import fetchAPI from '@lib/craft-cms/fetch-api';
import url from '@lib/craft-cms/url.ts';
const query = `{
entries {
title
uri
}
}`;
const schema = z.object({
entries: z
.object({
title: z.string(),
uri: z.string(),
})
.array(),
});
const data = await fetchAPI({
query,
schema,
});
---
<ul>
{
data?.entries.map((entry) => (
<li>
<a href={url(entry.uri)}>{entry.title}</a>
</li>
))
}
</ul>

Use content from beyond the entry

The above examples only query for and display a single entry’s content. In practice, a page might show entry content, global content, a list of other entries from other sections, etc.

That’s straight forward: add to the query and schema. Here’s an example

ts
ts
---
// …
const query = `{
entries${/* … */}
otherEntries: entries${/* … */}
}`;
const schema = z.object({
entries: z
.object(/* … */)
.array(),
otherEntries: z
.object(/* … */)
.array(),
});
const data = await fetchAPI({ query, schema });
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry./* … */}
{data.otherEntries.map((otherEntry) => (/* … */))}
ts
---
// …
const query = `{
entries${/* … */}
otherEntries: entries${/* … */}
}`;
const schema = z.object({
entries: z
.object(/* … */)
.array(),
otherEntries: z
.object(/* … */)
.array(),
});
const data = await fetchAPI({ query, schema });
if (data === undefined) {
return new Response(null, { status: 404 });
}
const entry = data.entries[0];
---
{entry./* … */}
{data.otherEntries.map((otherEntry) => (/* … */))}

Fetch data

The above pages all rely on fetch-api.ts. Here that is:

In the app directory, create the file src/lib/craft-cms/fetch-api.ts.

The default export is a function which takes a GraphQL query string and the response’s data’s expected shape as a Zod schema.

Do not explicitly pass in a generic.

The response data is validated against the schema prop.

  • If validation succeeds, the response’s data is returned. Its type is inferred from the schema.

  • If validation fails in production, an error is logged and undefined is returned. (Above, we set up pages tied to Craft sections —Singles and Channels and Structures to 404 if undefined is returned.)

  • If validation fails in development, an error is thrown.

I’m using zod-validation-error to prettify validation error messages. Add it as a dependency:

shell
shell
<your package manager> add zod-validation-error
shell
<your package manager> add zod-validation-error

Modify the console messages to match your style.

This uses, in order of appearance

./packages/app/src/lib/craft-cms/fetch-api.ts
ts
ts
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error';
/**
* Fetches and validates data from a GraphQL endpoint.
*
* @param query the GraphQL query
* @param schema the response's data's schema
* @returns
*/
export default async function fetchAPI<T extends z.ZodTypeAny>({
query,
schema,
}: {
query: string;
schema: T;
}): Promise<z.infer<T> | undefined> {
let json;
let response;
const url = import.meta.env.CRAFT_CMS_GRAPHQL_URL;
if (url === undefined) {
handleError('fetch-api: CRAFT_CMS_GRAPHQL_URL is not defined');
return undefined;
}
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
}),
});
json = await response.json();
} catch (error) {
let message = '';
if (!response?.ok) {
message = ['fetch-api: response not ok', response?.status].join('\n');
} else if (error instanceof SyntaxError) {
message = ['fetch-api: There was a SyntaxError', error].join('\n');
} else {
message = ['fetch-api: There was an error', error].join('\n');
}
handleError(message);
return undefined;
}
const result = schema.safeParse(json.data);
if (!result.success) {
const message = `fetch-api: ${fromZodError(result.error).toString()}`;
handleError(message);
return undefined;
}
return result.data as z.infer<T>;
}
function handleError(message: string): void {
if (import.meta.env.DEV) {
throw new Error(message);
}
console.error(message);
}
ts
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error';
/**
* Fetches and validates data from a GraphQL endpoint.
*
* @param query the GraphQL query
* @param schema the response's data's schema
* @returns
*/
export default async function fetchAPI<T extends z.ZodTypeAny>({
query,
schema,
}: {
query: string;
schema: T;
}): Promise<z.infer<T> | undefined> {
let json;
let response;
const url = import.meta.env.CRAFT_CMS_GRAPHQL_URL;
if (url === undefined) {
handleError('fetch-api: CRAFT_CMS_GRAPHQL_URL is not defined');
return undefined;
}
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
}),
});
json = await response.json();
} catch (error) {
let message = '';
if (!response?.ok) {
message = ['fetch-api: response not ok', response?.status].join('\n');
} else if (error instanceof SyntaxError) {
message = ['fetch-api: There was a SyntaxError', error].join('\n');
} else {
message = ['fetch-api: There was an error', error].join('\n');
}
handleError(message);
return undefined;
}
const result = schema.safeParse(json.data);
if (!result.success) {
const message = `fetch-api: ${fromZodError(result.error).toString()}`;
handleError(message);
return undefined;
}
return result.data as z.infer<T>;
}
function handleError(message: string): void {
if (import.meta.env.DEV) {
throw new Error(message);
}
console.error(message);
}

Deploy

Astro sites are static by default. Enabling SSR mode requires an adapter, and configuration. As of this writing there are official SSR Astro adapters for Cloudflare, Netlify, Node, and Vercel. Learn how to use them in Astro’s On-demand Rendering Adapters docs.

For the SSR Astro app to query the Craft CMS app with each page visit, the Craft app will have to be reachable over the internet. Hosting and deploying Craft CMS is beyond the scope of this guide. The Craft docs are a good place to start. See Hosting Craft CMS for hosting providers, or Hosting Craft 101 if you want to roll your own solution.

In each deployed Astro environment, set the CRAFT_CMS_GRAPHQL_URL env var. The design of fetch-api.ts supports pointing different Astro environments to different Craft API URLs— for example, you could have a staging Astro site showing data from a staging Craft database and a production Astro site showing data from a production Craft database.

And there you have it! A rendered-on-demand Astro site with content managed in Craft CMS!


Updates

  1. July 10, 2024: Validate fetched data.
  2. July 11, 2024: fetchAPI’s result.data’s type.
  3. July 12, 2024: fetchAPI errors throw in dev mode.

Articles You Might Enjoy

Or Go To All Articles