Monorepo Setup for Headless Craft CMS

Craft for the CMS, anything for the front end
Black-and-white photo an Ancient Greek statue of a man in a toga, cropped to show the figure from roughly the waist to above the shoulders. It is headless, the neck broken off the body
Headless statue of Aesculapius. Attribution 4.0 International (CC BY 4.0). Source: Wellcome Collection.

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.

Rough out the monorepo

  1. Create the monorepo directory structure. Run the following, replacing <replace with project name> with the project name:

    shell
    shell
    mkdir <replace with project name>/packages/cms
    cd <replace with project name>
    shell
    mkdir <replace with project name>/packages/cms
    cd <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.

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

    ./package.json
    json
    json
    {
    "name": "<replace with project name>",
    "workspaces": [
    "packages/*"
    ]
    }
    json
    {
    "name": "<replace with project name>",
    "workspaces": [
    "packages/*"
    ]
    }

    while for pnpm it’s

    ./package.json
    json
    json
    {
    "name": "<replace with project name>"
    }
    json
    {
    "name": "<replace with project name>"
    }
    ./pnpm-workspace.yaml
    yaml
    yaml
    packages:
    - "packages/*"
    yaml
    packages:
    - "packages/*"
  3. 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

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

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

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

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

The CMS package

Set up the Craft CMS package

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

  2. Create a new Craft CMS DDEV project. Run the following, replacing <replace with project name> with the project name

    shell
    shell
    cd packages/cms
    ddev config --project-type=craftcms --docroot=web --project-name=<replace with project name>
    ddev start
    ddev composer create craftcms/craft
    shell
    cd packages/cms
    ddev config --project-type=craftcms --docroot=web --project-name=<replace with project name>
    ddev start
    ddev composer create craftcms/craft
  3. Have DDEV automatically run composer install when the project starts. Add this to the end of packages/cms/.ddev/config.yaml:

    ./packages/cms/.ddev/config.yaml
    yaml
    yaml
    hooks:
    post-start:
    - exec: composer install
    yaml
    hooks:
    post-start:
    - exec: composer install
  4. Have DDEV move that hook configuration to its preferred position in .ddev/config.yaml:

    shell
    shell
    ddev config --disable-upload-dirs-warning --update
    ddev start
    shell
    ddev config --disable-upload-dirs-warning --update
    ddev start
  5. In packages/cms/package.json give your CMS package the project root package.json’s "name" with the suffix -cms.

    ./packages/cms/package.json
    json
    json
    {
    "name": "<replace with project name>-cms",
    json
    {
    "name": "<replace with project name>-cms",
  6. In packages/cms/package.json add some shortcut scripts.

    ./packages/cms/package.json
    json
    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"
    }
  7. Optionally, enable Craft’s Headless Mode to disable features which are not relevant in a headless context:

    ./packages/cms/config/general.php
    php
    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.

Define your Craft CMS content model

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.

Set up a Craft CMS GraphQL endpoint

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

  1. In the Craft CMS control panel (https://<replace with project name>.ddev.site/admin) click “GraphQL”.

  2. Click on “Public Schema”.

  3. Under “Sites”, check “Query for elements…”.

  4. 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”.

  5. Toggle “Enabled”.

  6. Save.

  7. Edit packages/cms/config/routes.php: add a URL rule to point the route api to the GraphQL API:

    ./packages/cms/config/routes.php
    php
    php
    // …
    return [
    'api' => 'graphql/api',
    php
    // …
    return [
    'api' => 'graphql/api',
  8. Confirm that it works: running this in your terminal (replace <replace with project name> with the project name)

    shell
    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"}}.

The app package

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!

Set up the front end package

  1. cd to the packages directory.

  2. Set up your front end app in the app directory. For example,

    shell
    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
    shell
    # new Eleventy project
    mkdir app && cd app && <your package manager> init && <your package manager> add @11ty/eleventy`
    shell
    # new Eleventy project
    mkdir app && cd app && <your package manager> init && <your package manager> add @11ty/eleventy`

    etc.

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

    ./packages/app/package.json
    json
    json
    {
    "name": "<replace with project name>-app",
    json
    {
    "name": "<replace with project name>-app",

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. (Replacing <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

Fetch and use Craft content in your front end app

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.

./packages/app/src/lib/craft-cms.ts
ts
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 var
if (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 var
if (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

path/to/my-view-file.ts
ts
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 {
title
uri
}
}`);
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 {
title
uri
}
}`);
const entries = data?.entries;

Deploying the app

  • For on-demand rendering, aka server-side rendering (SSR)

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

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

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

    1. Build your site locally (depending on your front end stack, this may take special architecting and/or configuring).

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

    3. Configure your deployment to not have a build command, and to use the build directory as the root.


Footnotes

  1. Not to be confused with an alot. ↩︎

  2. See my Uses. ↩︎

Articles You Might Enjoy

Or Go To All Articles