Storyblok Analytics: Visual Editor Tracking | OpsBlu Docs

Storyblok Analytics: Visual Editor Tracking

Implement analytics on Storyblok sites. Covers bridge event tracking, visual editor script injection, Nuxt/Next.js integration, and data layer setup...

Analytics Architecture

Storyblok is a headless CMS with a visual editor. It delivers content through the Content Delivery API (REST or GraphQL) and the Management API. The CMS itself does not render pages or execute JavaScript. Your frontend framework -- Next.js, Nuxt, Gatsby, SvelteKit, or any other -- handles rendering and is where all analytics scripts must live.

The Storyblok Bridge is a JavaScript library that enables real-time preview in the visual editor. It fires events (input, change, published) when editors modify content. You can hook into these events for editor-side analytics, but they only run inside the Storyblok editor iframe.

Because Storyblok sites are single-page applications, the browser does not perform full page reloads on navigation. Standard analytics snippets that rely on DOMContentLoaded or window.onload will only fire once. You need route change listeners in your frontend framework to track virtual page views on every navigation.

Data for the data layer comes from the Storyblok story object. Each story contains metadata (name, slug, tags, component type, publish date) that maps directly to analytics dimensions.

The typical data flow is: Storyblok API returns a story object, your frontend renders it, and a client-side hook pushes story metadata to the data layer. GTM reads the data layer and fires tags accordingly. Server-side rendering (SSR) or static site generation (SSG) affects timing -- data layer pushes must happen after hydration on the client, not during the server render pass.

Storyblok provides two API versions: the Content Delivery API v2 (CDN-backed, for published content) and the Management API (for draft content and administrative operations). Analytics implementations should use the Content Delivery API. The Management API is rate-limited more aggressively and intended for backend tooling, not frontend rendering.


Installing Scripts in Your Frontend

Tracking scripts go in your frontend framework's layout component. Since Storyblok does not control the HTML output, you manage script loading, execution order, and consent gates in the framework layer.

Next.js App Router example with GTM:

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <Script id="gtm" strategy="afterInteractive">
          {`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
          new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
          j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
          'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
          })(window,document,'script','dataLayer','GTM-XXXXXX');`}
        </Script>
      </head>
      <body>{children}</body>
    </html>
  );
}

For Nuxt 3, use useHead in app.vue or a layout file. For Gatsby, add scripts to gatsby-ssr.js via onRenderBody. The pattern is the same regardless of framework: load the container once in the outermost layout so it persists across route changes.

Set strategy="afterInteractive" in Next.js to avoid blocking the initial render. In Nuxt, use body: true to defer the script to the end of the body.

If you are using Storyblok with Nuxt 3 and the @storyblok/nuxt module, the module handles bridge script injection automatically. You do not need to load the Storyblok Bridge script manually. Your analytics scripts are separate and still need explicit installation in the layout.

For consent management, wrap the GTM script in a conditional check. If you use a consent management platform (CMP), load GTM only after the user grants consent. The strategy="afterInteractive" attribute in Next.js pairs well with this pattern because it delays execution until after hydration, giving the CMP time to render its consent banner first.

When using the @storyblok/react or @storyblok/vue SDK, the Storyblok Bridge script is loaded automatically in development and preview modes. This script makes network requests to app.storyblok.com which may appear in your network waterfall and affect performance metrics. The bridge script is not loaded in production builds by default, so it does not impact real-user performance data. Confirm this by checking your production build for references to storyblok-bridge.


Data Layer from Storyblok Content

Every Storyblok story object contains structured metadata. Push this data to the data layer on each page render so GTM tags can reference content attributes as variables.

// hooks/useStoryblokAnalytics.js
import { useEffect } from 'react';

export function useStoryblokAnalytics(story) {
  useEffect(() => {
    if (!story) return;
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'storyblok_page_view',
      content_type: story.content.component,
      story_name: story.name,
      story_id: story.id,
      story_slug: story.full_slug,
      first_published: story.first_published_at,
      tag_list: story.tag_list?.join(',') || ''
    });
  }, [story?.id]);
}

Use story.id as the useEffect dependency, not the full story object. The story object reference changes on every re-render even when the content has not changed, which would cause duplicate pushes.

In a page component, call the hook after fetching the story:

// app/[...slug]/page.tsx
import { useStoryblokAnalytics } from '@/hooks/useStoryblokAnalytics';

export default function StoryblokPage({ story }) {
  useStoryblokAnalytics(story);
  // render story content
}

For Nuxt, the same logic applies in a composable using watch on the story ref with { immediate: true }. The data layer keys remain identical across frameworks.

If you use Storyblok's folder structure for content organization, story.full_slug will include the folder path (e.g., blog/2024/my-post). Decide whether to track the full slug or just the final segment based on your reporting needs.

For multi-language sites using Storyblok's internationalization, add the locale to your data layer push. The story object includes a lang field (default for the primary language, or the locale code for translated versions). Track this as a custom dimension to segment analytics by language:

window.dataLayer.push({
  event: 'storyblok_page_view',
  content_type: story.content.component,
  story_id: story.id,
  story_slug: story.full_slug,
  content_language: story.lang === 'default' ? 'en' : story.lang
});

SPA Route Change Tracking

Single-page applications do not trigger traditional page load events on navigation. Without explicit route change tracking, GA4 and other tools will only record the initial landing page.

Create a provider component that listens for path changes:

// app/providers.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';

export function AnalyticsProvider({ children }) {
  const pathname = usePathname();
  useEffect(() => {
    window.dataLayer?.push({
      event: 'virtual_page_view',
      page_path: pathname
    });
  }, [pathname]);
  return <>{children}</>;
}

Wrap your layout with this provider:

// app/layout.tsx
import { AnalyticsProvider } from './providers';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}

In GTM, create a trigger on the custom event virtual_page_view and use the page_path data layer variable to set the page path on your GA4 page view tag. This replaces the built-in page view trigger, which only fires on full page loads.

If you use Storyblok with Gatsby or another static site generator that pre-renders pages, route change tracking still applies. Gatsby performs client-side navigation after the initial static load, so subsequent page views are not full loads. The gatsby-plugin-google-tagmanager plugin handles this automatically by firing a gatsby-route-change event, but if you manage GTM manually, you need the same virtual page view pattern shown above.

For search query parameters or UTM tracking, include window.location.search in the data layer push so campaign attribution is preserved across SPA navigations.


Storyblok Bridge Events

The Storyblok Bridge is a client-side script that enables real-time content preview in the visual editor. It communicates between the editor UI and your frontend via postMessage. You can hook into bridge events to track editor interactions.

// hooks/useStoryblokBridgeTracking.js
import { useEffect } from 'react';

export function useStoryblokBridgeTracking() {
  useEffect(() => {
    // Only run inside the Storyblok visual editor
    if (window.location === window.parent.location) return;

    const bridge = new window.StoryblokBridge();
    bridge.on('input', (event) => {
      console.log('[Storyblok Bridge] Content edited:', event.story?.name);
    });
    bridge.on('published', (event) => {
      window.dataLayer?.push({
        event: 'storyblok_content_published',
        story_id: event.storyId
      });
    });
    bridge.on('change', () => {
      window.dataLayer?.push({ event: 'storyblok_content_saved' });
    });
  }, []);
}

Bridge events only fire when the page is loaded inside the Storyblok editor iframe. The window.location === window.parent.location check prevents this code from executing on your production site. Use bridge tracking to measure editorial workflow metrics like time-to-publish or edit frequency. Do not use it for public-facing analytics.

The input event fires on every keystroke or field change in the visual editor. Debounce any analytics calls attached to this event to avoid flooding your analytics endpoint. The published event fires once when an editor clicks Publish, making it the most reliable event for tracking content publishing activity. The change event fires when content is saved as a draft.

If you use Storyblok's custom field types (plugins), the bridge still fires input events for those fields. The event payload includes the full story object with the updated field values, so you can track which custom fields are being edited most frequently.


Storyblok Rendering Modes and Analytics Impact

How you render your Storyblok site determines when and how analytics scripts execute.

Static Site Generation (SSG): Pages are pre-built at deploy time. Analytics scripts are embedded in the HTML and load immediately. Data layer pushes must happen client-side because the story data is baked into the page at build time. Use useEffect or onMounted to push after hydration. Stale content is only updated on the next build, so the data layer reflects the content version at build time, not the latest draft.

Server-Side Rendering (SSR): Pages are rendered on each request. The Storyblok API is called server-side, and the story object is available during rendering. However, window.dataLayer does not exist on the server. Any data layer pushes must still run client-side after hydration. The advantage of SSR is that the story data is always current, so the data layer reflects the latest published content.

Incremental Static Regeneration (ISR): A hybrid of SSG and SSR. Pages are statically generated but revalidated at a configured interval. Analytics behavior matches SSG, but the content freshness depends on the revalidation period. If you track first_published_at in the data layer, it will update when the page is regenerated.

For all three modes, the analytics script itself loads identically. The difference is in the story data available for the data layer push and the timing of that data relative to the analytics container initialization.

When using ISR or SSG with Storyblok webhooks to trigger rebuilds, there is a delay between content publishing and the updated data layer appearing on the live site. This means analytics data will reflect the previous content version until the rebuild completes. For time-sensitive content tracking, SSR eliminates this delay at the cost of higher server load.

If you use Storyblok's preview deployment feature (separate URL for draft content), run a separate analytics property or a GTM environment for the preview site to keep draft traffic out of production reports.


Common Errors

Error Cause Fix
Data layer empty on first render SSR hydration timing pushes data before GTM loads Push data layer in useEffect or onMounted, not during server render
Duplicate page views SPA re-renders trigger the effect multiple times Use story.id as the dependency, not the full story object
Draft content tracked in production Preview mode is not filtered from analytics Check storyblokApi.isPreview or the _storyblok query param before pushing events
Missing story data in data layer Storyblok API rate limit or network error Implement caching with ISR or SSG and add null checks before pushing
Events fire in visual editor Bridge loads the page in an iframe during preview Check window.location !== window.parent.location and skip analytics
GTM container not loading Content Security Policy blocks inline scripts Add a nonce attribute to the script tag or update the CSP script-src directive
Wrong page path tracked full_slug includes the Storyblok folder structure Use pathname from the router instead of story.full_slug for page path
Analytics blocked by ad blockers Client-side GTM and GA scripts are on common blocklists Use server-side GTM, a custom subdomain proxy, or the GA4 Measurement Protocol

Most of these issues stem from the headless architecture: the CMS and the frontend are separate systems, and the analytics layer must account for the gap between when content is fetched and when the page is interactive in the browser.