Directus Analytics: REST/GraphQL Data Layer | OpsBlu Docs

Directus Analytics: REST/GraphQL Data Layer

Implement analytics on Directus-powered sites. Covers REST and GraphQL API data layers, Flows-based server-side tracking, webhook event triggers, and...

Analytics Architecture

Directus wraps your existing SQL database (PostgreSQL, MySQL, SQLite, MS SQL, MariaDB, CockroachDB) with auto-generated REST and GraphQL APIs. It does not render a frontend. Your public-facing site runs on a separate framework -- Nuxt, Next.js, SvelteKit, Astro, or anything else that can call an API.

All client-side analytics scripts (GTM, GA4, Meta Pixel) must be installed in the frontend framework. Directus itself has no mechanism to inject JavaScript into your public pages. However, Directus Flows -- the built-in automation engine -- can trigger server-side analytics events when data changes. This gives you two tracking surfaces: client-side in the frontend and server-side via Flows or webhooks.

The Directus admin app (/admin) is a separate Vue application from your public site. Traffic to the admin interface should be excluded from production analytics. If both share a domain, filter admin paths in your analytics configuration or deploy them on separate subdomains.

Content fetched from the Directus API includes metadata (collection name, item ID, status, date fields) that you can push to the data layer for content-level reporting dimensions.

The typical tracking setup has two layers. First, client-side analytics in the frontend handles page views, user interactions, and conversion events. Second, server-side tracking via Directus Flows or webhooks captures backend events like content publishing, form submissions, or status changes that never touch the browser. Combining both gives you full coverage of the user journey and the content lifecycle.

Directus supports both self-hosted and Directus Cloud deployments. The analytics implementation is identical for both -- the only difference is the API URL. Self-hosted instances may require additional CORS and caching configuration that Directus Cloud handles automatically.


Installing Scripts in Your Frontend

Since Directus does not generate the HTML served to visitors, tracking scripts go in your frontend framework's layout. The Directus API is your data source; the frontend is your analytics host.

Nuxt 3 example with Google Analytics:

<!-- layouts/default.vue -->
<script setup>
useHead({
  script: [
    {
      src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX',
      async: true
    },
    {
      innerHTML: `window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());
        gtag('config', 'G-XXXXXX');`
    }
  ]
});
</script>

<template>
  <div>
    <slot />
  </div>
</template>

For Next.js, use the Script component in app/layout.tsx. For Astro, add the snippet to your base layout's <head>. For SvelteKit, add it to app.html or use svelte:head in +layout.svelte.

The pattern is consistent across frameworks: load the analytics container once in the outermost layout so it persists across all routes and client-side navigations.

If your Directus-powered site uses static generation (SSG), the analytics script is included in the built HTML and loads on every page without additional configuration. For server-side rendered (SSR) sites, verify that the script tag is present in the initial HTML response and not accidentally stripped by middleware.

For consent management, conditionally load the analytics script based on user consent. In Nuxt 3, you can dynamically add or remove scripts from useHead by wrapping them in a reactive consent check. In frameworks without built-in head management, toggle script injection via a consent callback.


Data Layer from Directus API

When your frontend fetches content from Directus, push the response metadata to the data layer. This enables GTM tags to reference content attributes (collection, author, publish date) as analytics dimensions.

// composables/useDirectusAnalytics.js
export async function useDirectusAnalytics(collection, itemId) {
  const { data } = await useFetch(
    `${process.env.DIRECTUS_URL}/items/${collection}/${itemId}`,
    { headers: { Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}` } }
  );

  if (data.value) {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'content_view',
      content_type: collection,
      content_id: itemId,
      content_title: data.value.data.title,
      content_status: data.value.data.status,
      date_published: data.value.data.date_published
    });
  }
}

Run this in onMounted (Vue) or useEffect (React) to ensure it executes on the client. Server-side rendering will not have access to window.dataLayer.

For public content, use the Directus public role with read-only permissions instead of an API token. This avoids exposing credentials in your frontend bundle. Configure the public role in Directus under Settings > Roles & Permissions to grant read access only to the collections your frontend needs.

If you use Directus's fields parameter to limit API responses, make sure you include any fields referenced in your data layer push. A minimal request like ?fields=title will omit status and date_published, resulting in undefined values in your analytics.

Directus supports relational fields. If your content has an author relation, you can resolve it in the API call and include author metadata in the data layer. Use the fields parameter with dot notation to fetch nested relations without making multiple API calls.

// Fetch with explicit fields for analytics
const { data } = await useFetch(
  `${DIRECTUS_URL}/items/${collection}/${itemId}?fields=title,status,date_published,author.name`,
  { headers: { Authorization: `Bearer ${token}` } }
);

SPA Route Change Tracking

If your Directus frontend is a single-page application (Nuxt, Next.js, SvelteKit), client-side navigation does not trigger full page loads. You need to track virtual page views on route changes.

Nuxt 3 example using a plugin:

// plugins/analytics.client.js
export default defineNuxtPlugin((nuxtApp) => {
  const router = useRouter();
  router.afterEach((to) => {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'virtual_page_view',
      page_path: to.fullPath,
      page_title: document.title
    });
  });
});

In GTM, create a custom event trigger for virtual_page_view and attach your GA4 page view tag to it. Disable the default page view trigger (History Change or Page View) to avoid counting duplicate events.

For Astro sites using Directus as the content backend, this is typically not needed. Astro performs full-page navigations by default unless you enable View Transitions or client-side routing with <ViewTransitions />. If you do enable View Transitions, add an astro:page-load event listener to push virtual page views.


Server-Side Tracking with Flows

Directus Flows is the built-in automation engine. A Flow consists of a trigger (event hook, schedule, or manual) and a chain of operations (webhooks, custom code, email, conditions). You can use Flows to send server-side analytics events when content is created, updated, or deleted -- without any client-side JavaScript.

This is useful for tracking events that do not happen in the browser: form submissions saved directly to Directus, content publishing workflows, or webhook-triggered integrations.

Example Flow that sends a GA4 Measurement Protocol event when a form submission is created:

{
  "name": "Track Form Submission",
  "trigger": "event",
  "options": {
    "scope": ["items.create"],
    "collections": ["form_submissions"]
  },
  "operations": [
    {
      "type": "request",
      "options": {
        "url": "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXX&api_secret=SECRET",
        "method": "POST",
        "body": {
          "client_id": "{{$trigger.payload.client_id}}",
          "events": [
            {
              "name": "form_submit",
              "params": {
                "form_name": "{{$trigger.payload.form_name}}"
              }
            }
          ]
        }
      }
    }
  ]
}

The client_id must be passed from your frontend form as a hidden field and stored in the form_submissions collection. Without a valid client_id, GA4 will accept the event (returning 204) but will not associate it with a user session. Generate the client ID on the frontend from the GA4 cookie (_ga) and include it in the form payload.

The api_secret is created in GA4 under Admin > Data Streams > your stream > Measurement Protocol API secrets. It is not the same as your Measurement ID.

You can extend this pattern to track other server-side events. For example, trigger a Flow when an item's status field changes from draft to published by adding a condition operation that checks the previous value against the new value. Chain multiple operations to send events to GA4, Segment, or a custom webhook simultaneously.

Flows run inside the Directus server process. If the Measurement Protocol endpoint is slow to respond, it can delay the Directus API response for the triggering action. Set a timeout on the request operation (under Advanced Options) to prevent analytics calls from blocking content operations. A timeout of 5 seconds is reasonable for Measurement Protocol requests.


Webhook-Based Analytics

Directus webhooks fire HTTP requests when items are created, updated, or deleted. Unlike Flows, webhooks are simpler to configure but offer less control over the request payload.

Configure webhooks in Settings > Webhooks. Set the target URL to your analytics endpoint or a middleware that transforms the payload:

Name: Content Published
URL: https://your-middleware.com/analytics/directus-webhook
Method: POST
Actions: create, update
Collections: articles, pages

Your middleware receives the Directus webhook payload (including the item data and action type) and can forward it to GA4 Measurement Protocol, Segment, or any other analytics destination.

Use webhooks when you need a lightweight integration without building a full Flow. Use Flows when you need conditional logic, payload transformation, or chaining multiple operations.

Add an idempotency key to your middleware to handle Directus webhook retries. If the initial request times out, Directus may resend the webhook, resulting in duplicate events. Store processed webhook IDs and skip duplicates.

Webhooks in Directus send the full item payload by default. For collections with large fields (rich text, JSON blobs), this can result in oversized payloads. If your analytics middleware only needs a few fields, consider using Flows instead, where you can construct a minimal request body with only the fields you need.

For sites that use Directus with multiple collections (articles, products, landing pages), set up separate webhooks per collection. This makes it easier to route events to different analytics destinations and avoids complex conditional logic in your middleware. Each webhook can target a different URL path on your middleware server.


Custom Extensions for Analytics

Directus supports custom extensions (hooks, endpoints, modules) that run server-side inside the Directus process. For analytics use cases that go beyond Flows and webhooks, a custom hook extension gives you full control over event handling.

A hook extension can intercept any Directus event (items.create, items.update, auth.login, etc.) and execute arbitrary JavaScript. This is useful for batching analytics events, enriching payloads with data from multiple collections, or integrating with analytics SDKs that require a Node.js runtime.

Place your extension in extensions/hooks/analytics-tracker/index.js within your Directus project. The extension runs on every Directus server restart and has access to the full Directus API via the services parameter. Use this approach when Flows and webhooks are too limited for your tracking requirements.

Keep hook extensions lightweight. Heavy processing in a hook delays the triggering API response. For expensive operations like aggregation queries or multi-step enrichment, emit the event to a message queue and process it asynchronously outside of Directus.

Note that custom extensions are only available in self-hosted Directus. Directus Cloud does not support custom extensions at this time. For cloud-hosted instances, use Flows and webhooks for server-side analytics.


Common Errors

Error Cause Fix
API token exposed in client bundle Using a server-side token in frontend fetch calls Use the Directus public role with read-only permissions or proxy API calls through your backend
CORS blocking API requests from analytics Directus CORS not configured for your frontend domain Set CORS_ORIGIN in the Directus environment to include your frontend's origin
Flows not triggering for public submissions Trigger scope set to wrong action or collection Verify the Flow trigger uses items.create with the correct collection name
Data layer missing on SSG pages Static generation runs at build time without window Push data layer values in onMounted (Vue) or useEffect (React), not during SSR
Duplicate events from Directus webhooks Webhook retries after timeout Add an idempotency key to your middleware and deduplicate by webhook delivery ID
Measurement Protocol returns 204 but no data Wrong api_secret or measurement_id in the request Verify both values in GA4 Admin > Data Streams > Measurement Protocol API secrets
Slow page loads from Directus API calls No caching on API responses Enable Directus built-in caching or use ISR/stale-while-revalidate in your frontend
Admin app activity tracked as user traffic Admin and public site share the same domain and analytics Filter /admin paths in GA4 or deploy the admin app on a separate subdomain

Many of these issues are specific to the headless architecture where the CMS backend and the analytics frontend operate independently. Testing your data layer with the browser console (console.log(window.dataLayer)) after page load is the fastest way to diagnose missing or malformed values. For server-side tracking via Flows, check the Directus Flows log (under Settings > Flows > your flow > Logs) to see execution history and error details.