23.10.2025
DevelopmentWhy I Prefer Datastar Over Vue + GraphQL for Most Craft CMS Projects
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
blog-vue.twig twig
<blog-list></blog-list>
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>
⚡ Datastar + Twig Approach
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>
_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 %}
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.