SSR Astro With Headless Craft CMS
Last Updated

Updates
- July 10, 2024: Validate fetched data.
- July 11, 2024:
fetchAPI’sresult.data’s type. - July 12, 2024:
fetchAPIerrors throw in dev mode. - July 31, 2024:
otherEntriesquery excludes current section.
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 third part covers SSG Astro with headless Craft, with CMS content fetched at build time. The fourth part covers SSG Astro with headless Craft, with the option of using cached data for the build.
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.)
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’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
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.
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.
Set up the Astro package
-
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-versionsfile in the project root, and specify anodejsversion. -
cdto thepackagesdirectory. -
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.
- Where should we create your new project?
-
In
packages/app/package.jsongive your front end package the project rootpackage.json’s"name"with the suffix-app../packages/app/package.jsonjson{ "name": "<replace with project name>-app", -
Add a path alias. In
packages/app/tsconfig.json(for TS users) orpackages/app/jsconfig.json(for JS users), point@tosrc:./packages/app/(jsconfig|tsconfig).jsonjson{ "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:
CRAFT_CMS_GRAPHQL_URL=Add .env to the app’s .gitignore file, if it isn’t already there.
.envCreate 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.)
CRAFT_CMS_GRAPHQL_URL=http://<replace with project name>.ddev.site/apiIf you’re using TypeScript, add the variable’s type information to src/env.d.ts:
// …
interface ImportMetaEnv {
readonly CRAFT_CMS_GRAPHQL_URL: string;
}Configure the Astro rendering mode
Use Astro’s “on-demand” rendering mode, aka SSR mode.
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:
<your package manager> add zodPages not tied to a Craft section
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.
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, parsing with Zod will error.
This uses, in order of appearance
---
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 });
---
{/* The response's data is now available to the template as `data`. */ }
<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 one section, by its handle.
Replace the query with your query, specifying the section’s handle in the section argument.
This uses, in order of appearance
- Zod’s array()
- Zod’s array().length()
- Zod’s object()
- Zod’s string()
---
/**
* 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 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. Replace the query with your query.
The query must include an entries query under the key “entries” (by specifying the GraphQL alias entries, or by not specifying an alias), and “entries” must have a uri argument the value of which is the section’s uri prefix followed by /${slug}.
If the response’s entries is empty, it’s a front end 404.
This uses, in order of appearance
- Zod’s array()
- Zod’s array().nonempty()
- Zod’s object()
- Zod’s string()
---
/**
* 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: "replace-me-with-the-section-uri-prefix/${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:
- JSDoc’s
@paramtag - JSDoc’s
@returnstag - TypeScript return type annotations
/**
* 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):
---
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
---
// …
const query = `{
entries(section: "thisSection") {
${/* … */}
}
otherEntries: entries(section: "!= thisSection") {
${/* … */}
}
}`;
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
undefinedis returned. (Above, we set up pages tied to Craft sections —Singles and Channels and Structures to 404 ifundefinedis 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:
<your package manager> add zod-validation-errorModify the console messages to match your style.
This uses, in order of appearance
- JSDoc’s
@paramtag - JSDoc’s
@returnstag - TypeScript generics
- Zod generic functions
- TypeScript destructuring in function declaration
- TypeScript return type annotations
- Zod’s
infer - JavaScript try…catch
- JavaScript fetch() global function
- Zod’s
safeParse - Zod error handling
zod-validation-error’sfromZodError
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!
On the web
Also In This Series
-
-
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!
-
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.
Articles You Might Enjoy
-
Comparing Heroku, Netlify, Vercel, and GitHub Pages for Node.js Projects

Running popular web-based CD tools against each other
-
-
From Stimulus to web components

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










