Back to Blog
Craft CMS GraphQL Headless API

Headless Craft CMS: GraphQL vs Element API vs Just Using Twig

· 13 min read

"Should we go headless?" is a question I get on almost every new project now. And my answer is usually the same: it depends on what you're building and who's going to maintain it.

Craft CMS gives you three solid options for how your front end talks to your content. You can use Twig templates (the traditional way), Craft's built-in GraphQL API, or the Element API plugin for REST-style JSON endpoints. Each one makes sense in different situations, and I've shipped production sites with all three.

Let me walk through each approach with real code so you can make an informed decision.

The Three Approaches at a Glance

Three Ways to Get Content Out of Craft Twig (Server-Side) Built in, no setup needed Server renders full HTML Best for SEO out of the box Live Preview just works BEST FOR: Marketing sites, blogs, content-heavy pages, most Craft projects COMPLEXITY: Low GraphQL API Built into Craft Pro Client picks exactly what data it needs Great for React/Vue/Next.js BEST FOR: SPAs, mobile apps, JS framework front ends, multi-platform content COMPLEXITY: Medium Element API (REST) Plugin by Pixel & Tonic Simple JSON endpoints You control the shape No query language to learn BEST FOR: Simple JSON feeds, AJAX page components, third-party integrations COMPLEXITY: Low

Option 1: Twig Templates (The Default)

This is how most Craft sites work and honestly how most Craft sites should work. Your templates live right alongside your CMS, Craft renders the HTML on the server, and the browser gets a fully formed page.

templates/blog/_entry.twig
{% extends "_layouts/base" %}

{% block content %}
  <article>
    <h1>{{ entry.title }}</h1>
    <time datetime="{{ entry.postDate|date('c') }}">
      {{ entry.postDate|date('F j, Y') }}
    </time>

    {% if entry.featuredImage.one() %}
      {% set image = entry.featuredImage.one() %}
      <img src="{{ image.url }}"
           alt="{{ image.alt }}"
           width="{{ image.width }}"
           height="{{ image.height }}">
    {% endif %}

    <div class="prose">
      {{ entry.body }}
    </div>

    {# Related entries #}
    {% set related = craft.entries
      .section('blog')
      .relatedTo(entry)
      .limit(3)
      .all() %}

    {% if related|length %}
      <h2>Related Posts</h2>
      {% for post in related %}
        <a href="{{ post.url }}">{{ post.title }}</a>
      {% endfor %}
    {% endif %}
  </article>
{% endblock %}

There's nothing wrong with this approach. It's fast to build, easy to maintain, great for SEO, and Craft's Live Preview works perfectly. The content team can see their changes in real time without any extra setup.

I'd estimate 70-80% of the Craft projects I work on use this approach exclusively. Don't let anyone tell you that server-rendered templates are outdated. For most content websites, they're still the best tool for the job.

Option 2: Craft's Built-in GraphQL API

Craft Pro includes a full GraphQL API. You don't need to install anything extra. Just go to Settings > GraphQL in the control panel, create a schema, define what content it can access, and you've got an API.

Setting Up a Schema

In the control panel, go to GraphQL > Schemas and create a new schema. You'll pick which sections, volumes, and other element types the schema can access. This is your security boundary. A public schema should only expose what you actually want public.

Be careful with the "Full Schema" option in development. It exposes everything, including drafts, user emails, and private fields. Always create a scoped schema for production, even if it takes a few extra minutes.

Querying Content

Here's a typical query to fetch blog posts:

GraphQL Query
// GraphQL query
const query = `
  query BlogPosts {
    entries(section: "blog", limit: 10, orderBy: "postDate DESC") {
      ... on blog_post_Entry {
        id
        title
        slug
        postDate
        url
        body
        featuredImage {
          url @transform(width: 800, height: 450, mode: "crop")
          alt
        }
      }
    }
  }
`

And here's how you'd fetch it from a Next.js or React app:

JavaScript fetch
async function getBlogPosts() {
  const response = await fetch('https://your-craft-site.com/api', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_GRAPHQL_TOKEN',
    },
    body: JSON.stringify({
      query: `
        query BlogPosts {
          entries(section: "blog", limit: 10, orderBy: "postDate DESC") {
            ... on blog_post_Entry {
              id
              title
              slug
              postDate
              body
              featuredImage {
                url @transform(width: 800, height: 450, mode: "crop")
                alt
              }
            }
          }
        }
      `,
    }),
  })

  const { data } = await response.json()
  return data.entries
}

GraphQL's big advantage is that the client asks for exactly the data it needs. No over-fetching, no under-fetching. If your front end only needs the title and URL, that's all it requests. If it also needs the full body and three levels of nested Matrix entries, it can ask for that too, all in one request.

Image Transforms in GraphQL

One of the nice things about Craft's GraphQL implementation is that you can request image transforms right in the query using the @transform directive:

GraphQL image transforms
// Request multiple sizes in one query
const query = `
  query {
    entry(slug: "my-post") {
      ... on blog_post_Entry {
        featuredImage {
          thumbnail: url @transform(width: 200, height: 200, mode: "crop")
          medium: url @transform(width: 800)
          full: url
          alt
          width
          height
        }
      }
    }
  }
`

When GraphQL Gets Complicated

GraphQL isn't free of tradeoffs. Here are the things that trip people up:

  • Matrix fields get verbose. If you have a content builder with 10 block types, your GraphQL query gets long. You end up writing inline fragments for every block type.
  • Live Preview needs extra work. With Twig, Live Preview works automatically. With a headless front end, you need to set up a preview endpoint that accepts Craft's preview token and fetches draft content.
  • Caching is more complex. Twig template caching is simple and well-understood. GraphQL response caching requires thinking about cache invalidation differently.
  • Two deployments instead of one. Your front end and CMS are separate applications now, which means separate hosting, separate deploys, and more infrastructure to manage.

Here's what a Matrix field query looks like when you have multiple block types. It's not terrible, but it's not exactly concise:

GraphQL Matrix query
const query = `
  query {
    entry(slug: "my-post") {
      ... on blog_post_Entry {
        contentBlocks {
          ... on contentBlocks_richText_Entry {
            typeHandle
            body
          }
          ... on contentBlocks_image_Entry {
            typeHandle
            image {
              url @transform(width: 1200)
              alt
            }
            caption
          }
          ... on contentBlocks_quote_Entry {
            typeHandle
            quoteText
            attribution
          }
          ... on contentBlocks_codeBlock_Entry {
            typeHandle
            code
            language
          }
        }
      }
    }
  }
`

Option 3: Element API

Element API is a first-party plugin from Pixel & Tonic (the folks who make Craft). It lets you define JSON endpoints using PHP config files. Think of it as a REST API that you define with Craft's element query syntax.

Install it with Composer:

Terminal
composer require craftcms/element-api
php craft plugin/install element-api

Then create your endpoints in a config file:

config/element-api.php
<?php

use craft\elements\Entry;
use craft\helpers\UrlHelper;

return [
    'endpoints' => [

        // List of blog posts
        'api/blog.json' => function() {
            return [
                'elementType' => Entry::class,
                'criteria' => [
                    'section' => 'blog',
                    'orderBy' => 'postDate desc',
                    'limit' => 20,
                ],
                'transformer' => function(Entry $entry) {
                    $image = $entry->featuredImage->one();
                    return [
                        'id' => $entry->id,
                        'title' => $entry->title,
                        'slug' => $entry->slug,
                        'url' => $entry->url,
                        'date' => $entry->postDate->format('c'),
                        'excerpt' => strip_tags(
                            (string) $entry->body
                        ),
                        'image' => $image
                            ? $image->getUrl(['width' => 800])
                            : null,
                    ];
                },
            ];
        },

        // Single blog post by slug
        'api/blog/<slug:{slug}>.json' => function(string $slug) {
            return [
                'elementType' => Entry::class,
                'criteria' => [
                    'section' => 'blog',
                    'slug' => $slug,
                ],
                'one' => true,
                'transformer' => function(Entry $entry) {
                    return [
                        'id' => $entry->id,
                        'title' => $entry->title,
                        'date' => $entry->postDate->format('c'),
                        'body' => (string) $entry->body,
                        'url' => $entry->url,
                    ];
                },
            ];
        },

    ],
];

Now you can hit https://your-site.com/api/blog.json and get a clean JSON response. No GraphQL query language needed, no schema to configure. You define the exact shape of the JSON in the transformer function.

When to Pick Element API Over GraphQL

I reach for Element API when:

  • I need a simple JSON feed for a specific purpose (search index, third-party integration, AJAX-loaded content)
  • The front-end team is more comfortable with REST than GraphQL
  • I want to tightly control what data is exposed without managing GraphQL schemas
  • The site is mostly Twig-rendered but has a few dynamic components that need JSON data

That last one is really common. You've got a normal Craft site with Twig templates, but the search results page or a filterable portfolio grid needs to fetch data via JavaScript. Element API is perfect for that. You don't need to go fully headless just because one page needs an API endpoint.

Element API and Twig templates can coexist on the same site without any issues. I do this all the time. The main site runs on Twig, and a few endpoints serve JSON for interactive components. Best of both worlds.

The Hybrid Approach

More and more, I'm building what I'd call hybrid sites. The main pages are server-rendered with Twig, and specific interactive features use API endpoints. Here's what that looks like in practice:

  • Blog listing, individual posts, static pages: all Twig
  • Search results with real-time filtering: Element API endpoint consumed by a small JavaScript component
  • A "load more" button on the blog: Element API endpoint with pagination
  • Mobile app that also needs the same content: GraphQL API

Craft is really good at this because nothing about these approaches is mutually exclusive. You can use all three on the same site.

Live Preview: The Deciding Factor

One thing that comes up in every "should we go headless" conversation is Live Preview. In a Twig-based site, Live Preview just works. Content editors click the preview button and see their changes in real time. It's one of Craft's best features.

With a headless front end, you have to build preview support yourself. The front end needs to:

  1. Accept a preview token from Craft via URL parameter
  2. Pass that token back to the API when fetching content
  3. Handle draft content that might have different fields or structures

Here's what a Next.js preview route looks like:

Next.js preview API route
// pages/api/preview.js
export default async function handler(req, res) {
  const { token, slug } = req.query

  if (!token) {
    return res.status(401).json({ message: 'No preview token' })
  }

  // Enable preview mode with the Craft token
  res.setPreviewData({ token })

  // Redirect to the page being previewed
  res.redirect(`/blog/${slug}`)
}

It's not a huge amount of work, but it's additional code to write, test, and maintain. If your content team relies heavily on Live Preview (and most do), factor that effort into your decision.

My Decision Framework

When a client asks me "should we go headless," I ask these questions:

  1. Does the content need to appear on multiple platforms? (web, mobile app, digital signage, etc.) If yes, headless makes sense. If it's just a website, probably not.
  2. Does the team have JavaScript framework experience? Going headless with Next.js or Nuxt means you need people who can maintain a Node.js application. If the team is mostly PHP developers, Twig is the faster path.
  3. How important is Live Preview? If the content team uses it constantly, stay with Twig or be prepared to invest time in preview support.
  4. What's the performance requirement? Server-rendered Twig with proper caching is very fast. A static-generated front end can be even faster, but the build/deploy complexity goes up.
  5. What's the budget? Headless adds infrastructure costs (separate hosting for the front end) and development time (two apps to build and maintain instead of one).

For most of the projects I work on, Twig templates with a few Element API endpoints for interactive features is the sweet spot. It's simple, it's fast to build, and the content team gets a great editing experience.

But when a project genuinely needs a decoupled architecture, like when there's a React Native mobile app that needs the same content, Craft's GraphQL API is solid and well-documented. It just requires more upfront investment.


The best architecture is the simplest one that meets your requirements. Don't add headless complexity because it sounds modern. Add it because your project actually needs it. And if you're not sure which direction makes sense for your project, let's talk through it.