23.10.2025

Development

Why I Prefer Datastar Over Vue + GraphQL for Most Craft CMS Projects

Block Text

When I first started working with Craft CMS, I loved pairing it with Vue and GraphQL. It felt powerful — almost limitless.

But over time, I realized something important: most projects don’t actually need a full frontend framework. What they need is something lean, reactive, and fast to build.

That’s where Datastar comes in — or more specifically, the Craft CMS Datastar plugin.

Datastar lets me work directly in Twig, without juggling component states, hydration issues, or complicated build pipelines. It keeps my stack minimal — and my focus where it belongs: on the content and experience.

With Datastar, things stay refreshingly simple:

  • ✅ No build step for frontend state
  • 🧭 No GraphQL boilerplate
  • 🪶 No global stores or routing headaches

I can sprinkle interactivity exactly where I need it — right inside my Twig templates — while keeping everything fast, reactive, and server-driven.

By contrast, working with Vue + GraphQL often meant:

  • Defining and maintaining schemas and queries
  • Manually handling cache and reactivity
  • Debugging hydration issues between server and client

With Datastar, I just add a few data- attributes — and I’m done.

My logic stays close to the markup. It scales surprisingly well. And the developer experience is honestly excellent.

Vue still has its place — for complex, highly dynamic UIs.

But for most content-heavy Craft CMS projects, Datastar is simply the faster, leaner, and more elegant solution.

🧱 Vue + GraphQL Approach

Block Code

blog-vue.twig twig

<blog-list></blog-list>
Block Code

blog-list.ce.vue javascript

<script setup>
import { ref, watch, onMounted } from 'vue'

const props = defineProps({
  endpoint: { type: String, default: '/api' },
  token: { type: String, default: '' },
  limit: { type: Number, default: 6 },
  section: { type: String, default: 'blog' },
})

const entries = ref([])
const categories = ref([])
const filter = ref('')
const loading = ref(false)
const hasMore = ref(true)
const offset = ref(0)

async function fetchGraphQL (query, variables = {}) {
  const res = await fetch(props.endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(props.token && { Authorization: `Bearer ${props.token}` }),
    },
    body: JSON.stringify({ query, variables }),
  })
  const json = await res.json()
  if (json.errors) {
    throw new Error(json.errors[0].message)
  }
  return json.data
}

async function fetchEntries (reset = false) {
  if (loading.value) {
    return
  }
  loading.value = true

  const query = `
    query ($section: [String], $limit: Int, $offset: Int, $category: [QueryArgument]) {
      entries(section: $section, limit: $limit, offset: $offset, relatedTo: $category, orderBy: "date desc") {
        id
        title
        url
        ... on etPageBlog_Entry {
          headline
          date
          card {
            teaser
            image {
              thumbnail: url(transform: "thumbnail")
              width
              height
              alt
            }
          }
          relationCategories {
            title
          }
        }
      }
    }
  `

  try {
    const data = await fetchGraphQL(query, {
      section: [props.section],
      limit: props.limit,
      offset: reset ? 0 : offset.value,
      category: filter.value ? parseInt(filter.value) : null,
    })

    const newEntries = data?.entries ?? []
    entries.value = reset ? newEntries : [...entries.value, ...newEntries]
    hasMore.value = newEntries.length >= props.limit
    offset.value = reset ? props.limit : offset.value + props.limit
  } catch (err) {
    console.error('GraphQL error:', err.message)
  }
  finally {
    loading.value = false
  }
}

async function fetchCategories () {
  const query = `
    query {
      entries(section: "categories") {
        id
        title
        slug
      }
    }
  `
  try {
    const data = await fetchGraphQL(query)
    categories.value = data?.entries ?? []
  } catch (err) {
    console.error('GraphQL error:', err.message)
  }
}

watch(filter, () => fetchEntries(true))

onMounted(() => {
  fetchCategories()
  fetchEntries(true)
})
</script>

<template>
  <div class="blocks__popout">
    <div v-if="categories.length" class="inline-flex items-center mb-6 space-x-4">
      <select
        v-model="filter"
        class="ui-select"
        :disabled="loading"
        aria-label="Blog-Kategorie filtern"
      >
        <option value="">Show all</option>
        <option v-for="cat in categories" :key="cat.id" :value="cat.id">
          {{ cat.title }}
        </option>
      </select>
      <div v-if="loading">Loading...</div>
    </div>

    <template v-if="entries.length">
      <div class="grid sm:grid-cols-2 md:grid-cols-3 gap-6">
        <article v-for="entry in entries" :key="entry.id" class="ui-card relative">
          <span
            v-if="entry.relationCategories?.length"
            class="absolute py-1 px-2 shadow ui-bold -top-3 right-5 bg-black/50 backdrop-blur-sm text-base text-white rounded-sm"
          >
            {{ entry.relationCategories[0].title }}
          </span>

          <div v-if="entry.card.image?.length" class="aspect-video mb-4">
            <img
              :src="entry.card.image[0].thumbnail"
              :width="entry.card.image[0].width"
              :height="entry.card.image[0].height"
              :alt="entry.card.image[0].alt"
              class="w-full h-full object-cover"
              loading="lazy"
            />
          </div>

          <div class="space-y-6 p-4">
            <p v-if="entry.date" class="text-base text-primary ui-bold mb-2">
              {{
                new Date(entry.date).toLocaleDateString('de-DE', {
                  day: '2-digit',
                  month: '2-digit',
                  year: 'numeric',
                })
              }}
            </p>
            <h2 class="ui-h5 mb-2">{{ entry.headline }}</h2>
            <div class="prose prose-lg" v-html="entry.card.teaser"></div>
            <a :href="entry.url" class="ui-btn">Read more</a>
          </div>
        </article>
      </div>
    </template>

    <p v-if="!loading && entries.length === 0" class="pt-4 pb-12 text-2xl">
      🫣 No results found.
    </p>

    <div class="mt-6" v-if="hasMore">
      <button @click="fetchEntries(false)" class="ui-btn ui-btn--lg">Load more</button>
    </div>
  </div>
</template>
Block Text

⚡ Datastar + Twig Approach

Block Code

blog-datastar.twig twig

{% set categories = craft.entries.section('categories').all() ?? null %}
{% if categories %}
    {# FILTER #}
    <div class="inline-flex items-center mb-6 space-x-4">
        <select
            data-bind-filter
            data-indicator-fetching
            data-attr-disabled="$fetching"
            data-on-input="{{ datastar.get('_datastar/blog.twig', { offset: 0 }) }}"
            aria-label="Blog-Kategorie filtern"
            class="ui-select">
            <option value="">{{ 'Show all'|t }}</option>
            {% for item in categories %}
            <option value="{{ item.slug }}" data-selected="$filter == {{ item.slug }}">{{ item.title }}</option>
            {% endfor %}
        </select>
        <div data-show="$fetching">
            Loading...
        </div>
    </div>
{% endif %}

{# RESULT #}
<div id="result" data-on-load="{{ datastar.get('_datastar/blog.twig', { offset: 0 }) }}"></div>
<div id="loadmore"></div>
</div>
Block Code

_datastar/blog.twig twig

{% set offset = offset ?? 0 %}
{% set limit = limit ?? 6 %}
{% set filter = signals.filter ?? '' %}

{% set entryAllCount = craft.entries.section('blog') %}
{% set entryQuery = craft.entries.section('blog').eagerly().limit(limit).offset(offset).orderBy('date DESC') %}

{% if filter %}
  {% set filterEntry = craft.entries.section('categories').slug(filter).one() %}
  {% do entryAllCount.andRelatedTo(filterEntry) %}
  {% do entryQuery.andRelatedTo(filterEntry) %}
{% endif %}

{% set entries = entryQuery.all() %}
{% set hasMore = entryAllCount.count() > offset + (entries|length) %}

{% if not entries %}
  {% patchelements %}
    <div id="result">
      <div class="text-2xl">
        <p>🫣 No results found for "{{ filter }}".</p>
      </div>
    </div>
  {% endpatchelements %}
  {% patchelements %}
    <div id="loadmore"></div>
  {% endpatchelements %}
{% else %}
  {% patchelements with {selector: '#result', mode: offset == 0 ? 'inner' : 'append'} %}
    {% for entry in entries %}
      {{ entry.render() }}
    {% endfor %}
  {% endpatchelements %}

  {% if hasMore %}
    {% patchelements %}
      <div id="loadmore" class="mt-6">
        <button data-on-click="{{ datastar.get('_datastar/blog.twig', {offset: offset + limit}) }}" class="ui-btn ui-btn--lg">{{ 'Load more'|t }}</button>
      </div>
    {% endpatchelements %}
  {% else %}
    {% patchelements %}
      <div id="loadmore"></div>
    {% endpatchelements %}
  {% endif %}
{% endif %}
Block Text

When comparing both approaches, the contrast speaks for itself.

⚙️ Vue + GraphQL introduces multiple layers — components, queries, build steps, and state management — just to achieve basic interactivity.

And that’s only part of it. You also need to:

  • Configure Vite and maintain a build pipeline
  • Define GraphQL schemas and set permissions
  • Handle authentication
  • Ship larger JavaScript bundles

All of this adds weight to projects that often don’t need it.

🚀 Datastar, on the other hand, keeps everything close to the template.

It delivers reactivity and dynamic behavior without the complexity of a full frontend stack:

  • No schema management
  • No complex routing
  • No hydration edge cases

✨ And because Datastar also handles UI interactions like navigation, modals, or simple stateful components, I don’t even need Alpine.js anymore. One lightweight tool is enough.

Lean. Fast. Maintainable.

This setup fits most content-driven Craft CMS projects perfectly — modern interactivity without the frontend bloat.