![](/posts/monorepo-setup-for-headless-craft-cms/JSO8xw2-Sw-240.jpeg)
Craft’s great data modelling and admin experience, with a server-rendered Astro front end.
This guide covers my monorepo template for headless Craft sites, how I spin up a new Craft project, and the basics of pulling data into the front end from the CMS.
Craft CMS is a PHP-based content management system with great DX and an accessible admin-facing control panel. By default, Craft projects are server-rendered PHP apps, with view files written in the templating language Twig. It can also be used headlessly with content fetchable from a GraphQL API endpoint.
Read More
This article is the first in a series. The next part covers SSR Astro with headless Craft.
I like Craft a lot[1]. From 2017 to 2023 I spend a significant amount of nearly every work week building or enhancing one or more Craft sites, many of them with large and complex data models. Craft’s data modelling capabilities and DX are great. The admin experience is great too, and notably accessible.
I like Twig pretty well. I coded front ends in it full-time for three years before my introduction to Craft. I’ve written a guide Fundamental Twig for Front-End Development. With Craft’s Twig extensions docs you can go far in full-stack development right from a Craft site’s view files, without having to write a line of PHP.
But it’s hard to get a good Twig authoring experience. VS Code VSCodium[2] has popular okay plugins for Twig intellisense and syntax highlighting, but there’s no good solution for formatting. Maybe PHPStorm users have it better, but —like many folks who do what ancients like me call “front end web development” and job description writers call “full stack web development”— I spend much more time in the JavaScript world than the PHP world, and don’t foresee myself picking a PHP-focused IDE and trading in .
for ->
. (Though every PHPStorm user I know used to say the same thing…)
With headless Craft CMS I can have it both ways: Craft for data modelling and content management, and anything of my choosing for the front end.
Create the monorepo directory structure. Run the following, replacing <replace with project name>
with the project name:
shell
mkdir <replace with project name>/packages/cmscd <replace with project name>
shell
mkdir <replace with project name>/packages/cmscd <replace with project name>
The Craft CMS project will live in packages/cms
. The front end of your choice (beyond the scope of this guide) will live in packages/app
; we’ll create that later in the guide.
Add a root package.json
with content per your package manager’s monorepo recommendation.
As of this writing, for Bun, npm, and Yarn that’s
json
{"name": "<replace with project name>","workspaces": ["packages/*"]}
json
{"name": "<replace with project name>","workspaces": ["packages/*"]}
while for pnpm it’s
json
{"name": "<replace with project name>"}
json
{"name": "<replace with project name>"}
yaml
packages:- "packages/*"
yaml
packages:- "packages/*"
Add top-level shorthand package scripts in ./package.json
for running app and CMS package scripts from the project root, with <package manager> run app <script name>
. The solution varies by package manager:
Bun
json
"scripts": {"app": "bun --filter <replace with project name>-app run","cms": "bun --filter <replace with project name>-cms run",
json
"scripts": {"app": "bun --filter <replace with project name>-app run","cms": "bun --filter <replace with project name>-cms run",
npm
json
"scripts": {"app": "npm --workspace <replace with project name>-app run","cms": "npm --workspace <replace with project name>-cms run",
json
"scripts": {"app": "npm --workspace <replace with project name>-app run","cms": "npm --workspace <replace with project name>-cms run",
pnpm
json
"scripts": {"app": "pnpm --filter <replace with project name>-app run","cms": "pnpm --filter <replace with project name>-cms run",
json
"scripts": {"app": "pnpm --filter <replace with project name>-app run","cms": "pnpm --filter <replace with project name>-cms run",
Yarn
json
"scripts": {"app": "yarn workspace <replace with project name>-app run","cms": "yarn workspace <replace with project name>-cms run",
json
"scripts": {"app": "yarn workspace <replace with project name>-app run","cms": "yarn workspace <replace with project name>-cms run",
If you don’t have Docker and/or DDEV, install them following the DDEV setup documentation.
Recommendation
On macOS, I use OrbStack rather than Docker Desktop. In my experience, it’s less resource intensive. It also supports Kubernetes and Linux VMs, so if you use either of those it’s a handy single solution.
Create a new Craft CMS DDEV project. Run the following, replacing <replace with project name>
with the project name
shell
cd packages/cmsddev config --project-type=craftcms --docroot=web --project-name=<replace with project name>ddev startddev composer create craftcms/craft
shell
cd packages/cmsddev config --project-type=craftcms --docroot=web --project-name=<replace with project name>ddev startddev composer create craftcms/craft
Have DDEV automatically run composer install
when the project starts. Add this to the end of packages/cms/.ddev/config.yaml
:
yaml
hooks:post-start:- exec: composer install
yaml
hooks:post-start:- exec: composer install
Have DDEV move that hook
configuration to its preferred position in .ddev/config.yaml
:
shell
ddev config --disable-upload-dirs-warning --updateddev start
shell
ddev config --disable-upload-dirs-warning --updateddev start
In packages/cms/package.json
give your CMS package the project root package.json
’s "name"
with the suffix -cms
.
json
{"name": "<replace with project name>-cms",
json
{"name": "<replace with project name>-cms",
In packages/cms/package.json
add some shortcut scripts.
json
"scripts": {"apply": "ddev craft project-config/apply","db-backup": "ddev craft db/backup --zip && printf '\n\nYou should move the newly created file in ./storage/backups to ./backups/db.sql/zip\n'","db-import": "ddev import-db --file=backups/db.sql.zip","launch": "ddev launch","restart": "ddev restart","start": "ddev start","stop": "ddev stop","touch": "ddev craft project-config/touch"}
json
"scripts": {"apply": "ddev craft project-config/apply","db-backup": "ddev craft db/backup --zip && printf '\n\nYou should move the newly created file in ./storage/backups to ./backups/db.sql/zip\n'","db-import": "ddev import-db --file=backups/db.sql.zip","launch": "ddev launch","restart": "ddev restart","start": "ddev start","stop": "ddev stop","touch": "ddev craft project-config/touch"}
Optionally, enable Craft’s Headless Mode to disable features which are not relevant in a headless context:
php
// …return GeneralConfig::create()->headlessMode(true)// DEFAULT SETTINGS// …
php
// …return GeneralConfig::create()->headlessMode(true)// DEFAULT SETTINGS// …
Why would you want to enable headless mode? To reduce control panel noise. Why would you not want to enable headless mode? If you want to use Twig templates to verify content.
Content modelling in Craft CMS is beyond the scope of this article. If you’re new to it, check out Craft Documentation > Intro to Craft CMS > Define a Content Model.
The official guide to this step is https://craftcms.com/docs/5.x/development/graphql.html#setting-up-your-api-endpoint.
As of this writing, the steps are
In the Craft CMS control panel (https://<replace with project name>.ddev.site/admin
) click “GraphQL”.
Click on “Public Schema”.
Under “Sites”, check “Query for elements…”.
Under “Entries”, check the sections you want to be able to access in Astro (probably all sections).
Remember
By default, newly created Craft CMS sections are not queriable. Every time you create a new section you want access to in the front end, go to the control panel > GraphQL, check the new section(s) under “Entries”, and “Save”.
Toggle “Enabled”.
Save.
Edit packages/cms/config/routes.php
: add a URL rule to point the route api
to the GraphQL API:
php
// …return ['api' => 'graphql/api',
php
// …return ['api' => 'graphql/api',
Confirm that it works: running this in your terminal (replace <replace with project name>
with the project name)
shell
curl -H "Content-Type: application/graphql" -d '{ping}' http://<replace with project name>.ddev.site/api
shell
curl -H "Content-Type: application/graphql" -d '{ping}' http://<replace with project name>.ddev.site/api
should return {"data":{"ping":"pong"}}
.
Note
The rest of this guide is only a starting point. Each front end framework/etc will have its own solution.
I’m writing a series of detailed guides to sites in various front ends with content from headless Craft CMS. The first is SSR Astro With Headless Craft CMS. Check it out!
cd
to the packages
directory.
Set up your front end app in the app
directory. For example,
shell
# a new Vue project<your package manager> create vue@latest app
shell
# a new Vue project<your package manager> create vue@latest app
or
shell
# new Eleventy projectmkdir app && cd app && <your package manager> init && <your package manager> add @11ty/eleventy`
shell
# new Eleventy projectmkdir app && cd app && <your package manager> init && <your package manager> add @11ty/eleventy`
etc.
If it doesn’t already exist, in the packages/app
directory create the file package.json
, and set the package’s "name"
to the project root package.json
’s "name"
with the suffix -app
.
json
{"name": "<replace with project name>-app",
json
{"name": "<replace with project name>-app",
Create a .env.example
file in the app
directory (if one does not already exist) and add this variable:
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. (Replacing <replace with project name>
with the project name)
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.
yaml
.env
yaml
.env
Add a GraphQL fetch helper. Here’s an example of what that might look like, and where it might live, for a Node stack app using TypeScript.
The method to access the CRAFT_CMS_GRAPHQL_URL
env var will depend on your app’s build tooling
Vite users will have import.meta.env.CRAFT_CMS_GRAPHQL_URL
.
Others will likely need to install dotenv or dotenvx, and then read process.env.CRAFT_CMS_GRAPHQL_URL
.
ts
/*** Fetches data from a GraphQL endpoint.** @template T the response's data's type* @param query the GraphQL query* @returns*/export default async function <T>(query: string): Promise<T | undefined> {let json;let response;const url = // refer to note above for how to access the CRAFT_CMS_GRAPHQL_URL env varif (url === undefined) {console.warn('fetch-api: url is undefined');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) {console.error('fetch-api: error', response?.ok ? error : response?.status);return undefined;}const { data }: { data: T } = json;return data;}
ts
/*** Fetches data from a GraphQL endpoint.** @template T the response's data's type* @param query the GraphQL query* @returns*/export default async function <T>(query: string): Promise<T | undefined> {let json;let response;const url = // refer to note above for how to access the CRAFT_CMS_GRAPHQL_URL env varif (url === undefined) {console.warn('fetch-api: url is undefined');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) {console.error('fetch-api: error', response?.ok ? error : response?.status);return undefined;}const { data }: { data: T } = json;return data;}
and then use it with something like
ts
import fetchAPI from "path/to/lib/craft-cms.ts"import url from '@lib/craft-cms/url.ts';interface Entry {title: string;uri: string,}interface Data {entries: Entry[];}const data = await fetchApi<Data>(`{entries {titleuri}}`);const entries = data?.entries;
ts
import fetchAPI from "path/to/lib/craft-cms.ts"import url from '@lib/craft-cms/url.ts';interface Entry {title: string;uri: string,}interface Data {entries: Entry[];}const data = await fetchApi<Data>(`{entries {titleuri}}`);const entries = data?.entries;
For on-demand rendering, aka server-side rendering (SSR)
Deploy your Craft CMS project so that it’s available over the internet. For recommended hosting services, see the official Hosting Craft CMS
docs. For rolling your own, see the official Hosting Craft 101 docs.
Add the CRAFT_CMS_GRAPHQL_URL
env var to the build environment (method varies by tooling. If using a continuous deployment (CD) service, check their docs for how to define environment variables.)
Build the app as part of the deployment (the standard for popular CD services like Cloudflare Pages, GitHub Pages, Netlify, Vercel, etc.)
For a prebuilt site (aka static site aka SSG site)
Build your site locally (depending on your front end stack, this may take special architecting and/or configuring).
Ensure that the build directory is known to your deployment service (for example, if you’re building from an online CD pipeline running off a hosted Git repo, make sure the build directory is not gitignored).
Configure your deployment to not have a build command, and to use the build directory as the root.
SSR Astro With Headless Craft CMS
Craft’s great data modelling and admin experience, with a server-rendered Astro front end.
Watch for specific added nodes with MutationObserver
MutationObserver makes it easy to watch for the addition of specific nodes, if you know where to drill.
Trying Out Bun For JavaScript Package Management
It's super fast! Sometimes.