How to Fix Strapi Tracking Events Not Firing | OpsBlu Docs

How to Fix Strapi Tracking Events Not Firing

Fix GA4, GTM, and pixel events not firing on Strapi — headless frontend SSR/SSG timing, REST/GraphQL data layer issues, and client-side routing debugging

Learn how to troubleshoot and fix tracking issues on Strapi-powered sites. Since Strapi is a headless CMS, most tracking problems occur in your frontend framework, not in Strapi itself.

For general tracking troubleshooting, see the global tracking troubleshooting guide.

Common Strapi-Specific Issues

1. SSR/SSG Timing Issues

Problem: Tracking scripts run on server instead of client.

Symptoms:

  • Error: "window is not defined"
  • Error: "gtag is not defined"
  • Events don't fire at all
  • No errors, but nothing happens

Diagnosis:

Check if code runs on server:

console.log('Is server?', typeof window === 'undefined');

Solutions:

A. Always Check for Window

// Bad - runs on server
export default function ArticlePage({ article }) {
  window.gtag('event', 'view_content', { /* ... */ });
  // Error: window is not defined

  return <article>...</article>;
}

// Good - client-only
export default function ArticlePage({ article }) {
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', 'view_content', { /* ... */ });
  }

  return <article>...</article>;
}

// Best - use framework hooks
'use client'; // Next.js App Router

export default function ArticlePage({ article }) {
  useEffect(() => {
    // Only runs on client
    window.gtag('event', 'view_content', {
      content_id: article.id,
      content_title: article.attributes.title,
    });
  }, [article]);

  return <article>...</article>;
}

B. Next.js App Router - Mark Client Components

// app/articles/[slug]/page.tsx
import { ArticleContent } from './ArticleContent';

// This is a Server Component (default)
export default async function ArticlePage({ params }) {
  const article = await fetchArticle(params.slug);

  return <ArticleContent article={article} />;
}

// app/articles/[slug]/ArticleContent.tsx
'use client'; // Mark as Client Component

import { useEffect } from 'react';

export function ArticleContent({ article }) {
  useEffect(() => {
    // Safe to use window here
    if (window.gtag) {
      window.gtag('event', 'view_content', {
        content_id: article.id,
      });
    }
  }, [article]);

  return <article>...</article>;
}

C. Gatsby - Use useEffect

// src/templates/article.js
import React, { useEffect } from 'react';

const ArticleTemplate = ({ data }) => {
  const article = data.strapiArticle;

  useEffect(() => {
    // Runs only on client after hydration
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', 'view_content', {
        content_id: article.strapiId,
      });
    }
  }, [article]);

  return <article>...</article>;
};

D. Nuxt - Use onMounted

<!-- pages/articles/[slug].vue -->
<script setup>
const article = await useFetch('/api/articles/...');

onMounted(() => {
  // Only runs on client
  if (window.gtag) {
    window.gtag('event', 'view_content', {
      content_id: article.value.data.id,
    });
  }
});
</script>

2. API Data Not Loaded When Tracking Fires

Problem: Event fires before Strapi data is available.

Symptoms:

  • Events fire with undefined values
  • Content ID is null
  • Parameters missing in analytics

Diagnosis:

useEffect(() => {
  console.log('Article data:', article);
  // Check if article is undefined or null
}, [article]);

Solutions:

A. Wait for Data Before Tracking

// Bad - data might not be loaded
export function ArticleView({ slug }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    // Track immediately - article is still null!
    trackArticleView(article); // article is null

    // Fetch data
    fetchArticle(slug).then(setArticle);
  }, [slug]);
}

// Good - track only when data available
export function ArticleView({ slug }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    fetchArticle(slug).then(setArticle);
  }, [slug]);

  useEffect(() => {
    if (article) {
      // Only track when article loaded
      trackArticleView(article); // article has data
    }
  }, [article]);
}

B. Use SSR/SSG to Pre-Load Data

// Next.js - data already loaded
export default async function ArticlePage({ params }) {
  // Data fetched on server
  const article = await fetchArticle(params.slug);

  // Pass to client component
  return <ArticleTracking article={article} />;
}

// Client component receives ready data
'use client';
export function ArticleTracking({ article }) {
  useEffect(() => {
    // Article data already available
    window.gtag('event', 'view_content', {
      content_id: article.id,
      content_title: article.attributes.title,
    });
  }, [article]);

  return null;
}

C. Handle Loading States

export function ArticlePage({ slug }) {
  const { data: article, isLoading } = useSWR(
    `/api/articles/${slug}`,
    fetcher
  );

  useEffect(() => {
    // Don't track while loading
    if (isLoading || !article) return;

    // Track only when data ready
    window.gtag('event', 'view_content', {
      content_id: article.id,
      content_title: article.attributes.title,
    });
  }, [article, isLoading]);
}

3. Route Changes Not Tracked

Problem: Navigation between pages doesn't fire page views.

Symptoms:

  • First page view works
  • Subsequent page views don't fire
  • Hard refresh works, but client navigation doesn't

Diagnosis:

// Check if router events fire
router.events.on('routeChangeComplete', (url) => {
  console.log('Route changed to:', url);
});

Solutions:

A. Next.js Pages Router

// pages/_app.js
import { useRouter } from 'next/router';
import { useEffect } from 'react';

function MyApp({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() => {
    const handleRouteChange = (url) => {
      if (window.gtag) {
        window.gtag('config', process.env.NEXT_PUBLIC_GA_ID, {
          page_path: url,
        });
      }

      if (window.fbq) {
        window.fbq('track', 'PageView');
      }

      if (window.dataLayer) {
        window.dataLayer.push({
          event: 'page_view',
          page: url,
        });
      }
    };

    router.events.on('routeChangeComplete', handleRouteChange);

    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router.events]);

  return <Component {...pageProps} />;
}

B. Next.js App Router

// app/components/RouteTracker.tsx
'use client';

import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

export function RouteTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : '');

    // Track page view on route change
    if (window.gtag) {
      window.gtag('config', process.env.NEXT_PUBLIC_GA_ID, {
        page_path: url,
      });
    }

    if (window.fbq) {
      window.fbq('track', 'PageView');
    }
  }, [pathname, searchParams]);

  return null;
}

// Add to layout
// app/layout.tsx
import { Suspense } from 'react';
import { RouteTracker } from './components/RouteTracker';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Suspense fallback={null}>
          <RouteTracker />
        </Suspense>
      </body>
    </html>
  );
}

C. Gatsby

// gatsby-browser.js
export const location }) => {
  if (typeof window !== 'undefined') {
    if (window.gtag) {
      window.gtag('config', process.env.GA_MEASUREMENT_ID, {
        page_path: location.pathname,
      });
    }

    if (window.fbq) {
      window.fbq('track', 'PageView');
    }
  }
};

D. Nuxt

// Nuxt 3 - plugins/router.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('page:finish', () => {
    if (window.gtag) {
      window.gtag('config', process.env.GA_MEASUREMENT_ID, {
        page_path: window.location.pathname,
      });
    }

    if (window.fbq) {
      window.fbq('track', 'PageView');
    }
  });
});

4. GTM Container Not Loading

Problem: GTM script doesn't load or initialize.

Symptoms:

  • window.dataLayer is undefined
  • GTM Preview mode doesn't connect
  • No GTM requests in Network tab

Diagnosis:

// Check in browser console
console.log('dataLayer exists?', typeof window.dataLayer !== 'undefined');
console.log('GTM loaded?', typeof window.google_tag_manager !== 'undefined');

Solutions:

A. Ensure GTM Script in <head>

// Next.js App Router
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <Script
          id="gtm-script"
          strategy="afterInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              (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','${process.env.NEXT_PUBLIC_GTM_ID}');
            `,
          }}
        />
      </head>
      <body>
        <noscript>
          <iframe
            src={`https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}`}
            height="0"
            width="0"
            style={{ display: 'none', visibility: 'hidden' }}
          />
        </noscript>
        {children}
      </body>
    </html>
  );
}

B. Initialize Data Layer Before GTM

// Ensure dataLayer exists before GTM loads
<Script
  id="gtm-init"
  strategy="afterInteractive"
  dangerouslySetInnerHTML={{
    __html: `
      window.dataLayer = window.dataLayer || [];
    `,
  }}
/>
<Script id="gtm-script" strategy="afterInteractive">
  {/* GTM script here */}
</Script>

5. Events Fire Multiple Times

Problem: Same event fires 2+ times for single action.

Symptoms:

  • Duplicate events in GA4/Meta
  • Event count inflated
  • Multiple entries in GTM debugger

Diagnosis:

// Add counter to track calls
let viewContentCallCount = 0;
useEffect(() => {
  viewContentCallCount++;
  console.log('View content called:', viewContentCallCount);
}, [article]);

Solutions:

A. Fix useEffect Dependencies

// Bad - fires on every render
useEffect(() => {
  trackArticleView(article);
}); // No dependency array

// Bad - fires when any article property changes
useEffect(() => {
  trackArticleView(article);
}, [article]); // Object reference changes

// Good - fires only when ID changes
useEffect(() => {
  if (article?.id) {
    trackArticleView(article);
  }
}, [article?.id]); // Only when article changes

B. Remove Duplicate Tracking Implementations

// Check for multiple implementations
console.log('GA4 loaded?', typeof window.gtag !== 'undefined');
console.log('How many times?', document.querySelectorAll('script[src*="googletagmanager.com/gtag"]').length);

// Should be 1, not 2+

C. Use Ref to Track if Already Fired

export function ArticleView({ article }) {
  const trackedRef = useRef(false);

  useEffect(() => {
    if (trackedRef.current) return; // Already tracked

    if (article?.id) {
      trackArticleView(article);
      trackedRef.current = true;
    }
  }, [article]);
}

6. Ad Blockers Blocking Tracking

Problem: Ad blockers prevent tracking scripts.

Symptoms:

  • Works in incognito mode
  • Works with ad blocker disabled
  • Network requests blocked

Solutions:

A. Test Without Ad Blockers

// Detect if tracking blocked
setTimeout(() => {
  if (typeof window.gtag === 'undefined') {
    console.warn('GA4 blocked - likely ad blocker');
  }
  if (typeof window.fbq === 'undefined') {
    console.warn('Meta Pixel blocked - likely ad blocker');
  }
}, 2000);

B. Use Server-Side Tracking

// app/api/track-event/route.ts
export async function POST(request: Request) {
  const { event, params } = await request.json();

  // Send to GA4 Measurement Protocol
  const response = await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=${process.env.GA_MEASUREMENT_ID}&api_secret=${process.env.GA_API_SECRET}`,
    {
      method: 'POST',
      body: JSON.stringify({
        client_id: params.client_id,
        events: [{ name: event, params }],
      }),
    }
  );

  return Response.json({ success: response.ok });
}

// Client-side
async function trackEvent(event, params) {
  // Try browser tracking first
  if (window.gtag) {
    window.gtag('event', event, params);
  } else {
    // Fallback to server-side
    await fetch('/api/track-event', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ event, params }),
    });
  }
}

7. Strapi API Populate Not Returning Data

Problem: Tracking data is undefined because Strapi relations not populated.

Symptoms:

  • category.data is null/undefined
  • author.data is null/undefined
  • Nested relations missing

Solutions:

A. Use Populate Parameter

// Bad - relations not populated
const response = await fetch(
  `${STRAPI_URL}/api/articles/${id}`
);
// Returns: { author: { data: null } }

// Good - populate relations
const response = await fetch(
  `${STRAPI_URL}/api/articles/${id}?populate=*`
);
// Returns full author data

// Best - populate specific fields
const response = await fetch(
  `${STRAPI_URL}/api/articles/${id}?populate[author][populate][avatar]=*&populate[category]=*`
);

B. Check Data Structure Before Tracking

export function trackArticleView(article: any) {
  const category = article.attributes?.category?.data?.attributes?.name;
  const author = article.attributes?.author?.data?.attributes?.name;

  window.gtag('event', 'view_content', {
    content_id: article.id,
    content_title: article.attributes?.title || 'Unknown',
    content_category: category || 'Uncategorized',
    author: author || 'Unknown',
  });
}

Debugging Tools

Browser Console Checks

// Check if analytics loaded
console.log('GA4:', typeof window.gtag);
console.log('Meta Pixel:', typeof window.fbq);
console.log('GTM:', typeof window.google_tag_manager);
console.log('Data Layer:', window.dataLayer);

// Monitor data layer pushes
const originalPush = window.dataLayer.push;
window.dataLayer.push = function() {
  console.log('Data Layer Push:', arguments[0]);
  return originalPush.apply(window.dataLayer, arguments);
};

// Test manual event
if (window.gtag) {
  window.gtag('event', 'test_event', { test: 'value' });
  console.log('Test event sent');
}

Network Tab Inspection

Check for requests to:

  • www.google-analytics.com (GA4)
  • www.googletagmanager.com (GTM)
  • connect.facebook.net (Meta Pixel)
  • Your Strapi API

GTM Preview Mode

  1. In GTM, click Preview
  2. Enter your site URL
  3. Check which tags fire
  4. Inspect data layer values
  5. Look for errors in Summary

GA4 DebugView

// Enable debug mode
gtag('config', process.env.NEXT_PUBLIC_GA_ID, {
  debug_mode: true,
});

// Or in GTM, use Preview mode
// Events automatically appear in GA4 DebugView

Framework-Specific Debugging

Next.js

# Check build type
npm run build

# Look for:
# ○ Static (SSG)
# λ Server (SSR)
# ƒ Dynamic (SSR with caching)

# SSG pages: tracking should work immediately
# SSR pages: ensure client-side tracking

Gatsby

# Clear cache and rebuild
gatsby clean
gatsby develop

# Check GraphQL query
http://localhost:8000/___graphql

# Verify Strapi data structure

Nuxt

# Check rendering mode
npm run build

# Look for SSR/SSG indicators
# Verify client-side hydration

Testing Checklist

  • Test in development mode
  • Test production build
  • Test with ad blockers disabled
  • Test in incognito/private mode
  • Test on different browsers
  • Test on mobile devices
  • Check browser console for errors
  • Verify in GA4 Realtime/DebugView
  • Verify in Meta Events Manager
  • Check GTM Preview mode
  • Test hard refresh vs client navigation
  • Verify Strapi API data populated

Common Error Messages

"window is not defined"

Solution: Add window check or use useEffect

if (typeof window !== 'undefined' && window.gtag) {
  window.gtag('event', 'my_event');
}

"gtag is not a function"

Solution: Script not loaded yet or blocked

// Wait for script to load
useEffect(() => {
  const checkGtag = setInterval(() => {
    if (window.gtag) {
      window.gtag('event', 'my_event');
      clearInterval(checkGtag);
    }
  }, 100);

  return () => clearInterval(checkGtag);
}, []);

"Cannot read property 'attributes' of undefined"

Solution: Strapi API response not structured correctly

// Add null checks
const title = article?.attributes?.title || 'Default';
const category = article?.attributes?.category?.data?.attributes?.name;

Webhook Debugging for Server-Side Events

Debug webhook-based tracking when content changes don't trigger analytics events.

Common Webhook Issues

1. Webhook Not Firing

Symptoms:

  • Content published in Strapi, but no event in GA4
  • Webhook shows "Not sent" in Strapi logs
  • No requests appear in server logs

Diagnosis:

# Check Strapi webhook logs
# In Strapi admin: Settings → Webhooks → Click on webhook → View logs

# Check your server logs
tail -f /var/log/your-app/webhook.log

# Test webhook manually
curl -X POST https://your-site.com/api/webhook/strapi-tracking \
  -H "Content-Type: application/json" \
  -d '{
    "event": "entry.publish",
    "model": "article",
    "entry": {
      "id": 1,
      "title": "Test Article"
    }
  }'

Solutions:

A. Verify Webhook Configuration

// In Strapi Admin
// Settings → Webhooks → Your webhook

// Verify:
// - URL is correct and publicly accessible
// - Events are selected (entry.create, entry.publish, etc.)
// - Webhook is enabled
// - Headers are correct (if using authentication)

B. Check Network Accessibility

# From your Strapi server, test if webhook URL is reachable
curl -I https://your-site.com/api/webhook/strapi-tracking

# Should return 200 or 405 (method not allowed is OK for GET)
# 404 means endpoint doesn't exist
# Timeout means network issue

C. Enable Strapi Webhook Logging

// config/server.js
module.exports = ({ env }) => ({
  webhooks: {
    populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
  },
  logger: {
    level: 'debug', // Enable debug logging
    logs: {
      admin: 'info',
      webhook: 'debug', // Enable webhook logging
    },
  },
});

2. Webhook Fires But Handler Fails

Symptoms:

  • Webhook shows "Sent" in Strapi
  • Events still don't appear in GA4
  • Error logs in your server

Diagnosis:

// app/api/webhook/strapi-tracking/route.ts
export async function POST(request: NextRequest) {
  console.log('Webhook received');

  try {
    const payload = await request.json();
    console.log('Webhook payload:', JSON.stringify(payload, null, 2));

    // Your webhook handler code
    await sendToGA4(/* ... */);

    console.log('Webhook processed successfully');
    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Webhook error:', error);
    // Return error details
    return NextResponse.json(
      {
        error: error.message,
        stack: error.stack,
      },
      { status: 500 }
    );
  }
}

Solutions:

A. Validate Payload Structure

// app/api/webhook/strapi-tracking/route.ts
import { z } from 'zod';

const StrapiWebhookSchema = z.object({
  event: z.string(),
  model: z.string(),
  entry: z.object({
    id: z.number(),
    title: z.string().optional(),
    name: z.string().optional(),
  }),
});

export async function POST(request: NextRequest) {
  try {
    const payload = await request.json();

    // Validate payload
    const validatedPayload = StrapiWebhookSchema.parse(payload);

    // Use validated data
    await sendToGA4({
      name: mapEventName(validatedPayload.event),
      params: {
        content_id: validatedPayload.entry.id,
        content_type: validatedPayload.model,
      },
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Invalid webhook payload:', error.errors);
      return NextResponse.json(
        { error: 'Invalid payload', details: error.errors },
        { status: 400 }
      );
    }

    console.error('Webhook processing error:', error);
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

B. Test GA4 Measurement Protocol

// Test if GA4 Measurement Protocol is working
async function testGA4MeasurementProtocol() {
  const measurementId = process.env.GA_MEASUREMENT_ID!;
  const apiSecret = process.env.GA_API_SECRET!;

  console.log('Testing GA4 Measurement Protocol...');
  console.log('Measurement ID:', measurementId);
  console.log('API Secret:', apiSecret ? 'Set' : 'Missing');

  const response = await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${apiSecret}`,
    {
      method: 'POST',
      body: JSON.stringify({
        client_id: 'test-client',
        events: [
          {
            name: 'test_event',
            params: {
              test_param: 'test_value',
            },
          },
        ],
      }),
    }
  );

  console.log('Response status:', response.status);
  console.log('Response:', await response.text());

  return response.ok;
}

C. Add Retry Logic

async function sendToGA4WithRetry(event: any, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(
        `https://www.google-analytics.com/mp/collect?measurement_id=${process.env.GA_MEASUREMENT_ID}&api_secret=${process.env.GA_API_SECRET}`,
        {
          method: 'POST',
          body: JSON.stringify({
            client_id: 'strapi-webhook',
            events: [event],
          }),
        }
      );

      if (response.ok) {
        console.log(`GA4 event sent successfully (attempt ${i + 1})`);
        return true;
      }

      console.error(`GA4 request failed (attempt ${i + 1}):`, response.statusText);
    } catch (error) {
      console.error(`GA4 request error (attempt ${i + 1}):`, error);
    }

    // Wait before retry (exponential backoff)
    if (i < maxRetries - 1) {
      await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000));
    }
  }

  console.error('GA4 event failed after all retries');
  return false;
}

3. Webhook Signature Verification Fails

Symptoms:

  • Webhook returns 401 Unauthorized
  • Signature validation error in logs

Solutions:

A. Debug Signature Calculation

import crypto from 'crypto';

export async function POST(request: NextRequest) {
  const payload = await request.json();
  const receivedSignature = request.headers.get('x-strapi-signature');

  // Debug signature
  console.log('Received signature:', receivedSignature);
  console.log('Webhook secret:', process.env.STRAPI_WEBHOOK_SECRET ? 'Set' : 'Missing');

  // Calculate expected signature
  const expectedSignature = crypto
    .createHmac('sha256', process.env.STRAPI_WEBHOOK_SECRET!)
    .update(JSON.stringify(payload))
    .digest('hex');

  console.log('Expected signature:', expectedSignature);
  console.log('Signatures match:', receivedSignature === expectedSignature);

  if (receivedSignature !== expectedSignature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Process webhook
  // ...
}

B. Verify Strapi Webhook Configuration

// In Strapi: config/server.js
module.exports = ({ env }) => ({
  webhooks: {
    defaultHeaders: {
      'x-strapi-signature': env('WEBHOOK_SECRET'),
    },
  },
});

4. Webhook Timeout Issues

Symptoms:

  • Webhook shows timeout in Strapi logs
  • Slow webhook processing

Solutions:

A. Process Webhook Asynchronously

// app/api/webhook/strapi-tracking/route.ts
export async function POST(request: NextRequest) {
  const payload = await request.json();

  // Immediately respond to Strapi
  const response = NextResponse.json({ success: true });

  // Process webhook asynchronously (don't await)
  processWebhookAsync(payload).catch((error) => {
    console.error('Async webhook processing failed:', error);
  });

  return response;
}

async function processWebhookAsync(payload: any) {
  // This runs after response is sent
  await sendToGA4({
    name: mapEventName(payload.event),
    params: {
      content_id: payload.entry.id,
      content_type: payload.model,
    },
  });
}

B. Use Queue for Webhook Processing

// Use a queue like Bull or BullMQ
import { Queue } from 'bullmq';

const webhookQueue = new Queue('strapi-webhooks', {
  connection: {
    host: process.env.REDIS_HOST,
    port: Number(process.env.REDIS_PORT),
  },
});

export async function POST(request: NextRequest) {
  const payload = await request.json();

  // Add to queue
  await webhookQueue.add('process-webhook', payload);

  // Immediately respond
  return NextResponse.json({ success: true });
}

// Worker process (separate file or process)
import { Worker } from 'bullmq';

const worker = new Worker('strapi-webhooks', async (job) => {
  const payload = job.data;

  await sendToGA4({
    name: mapEventName(payload.event),
    params: {
      content_id: payload.entry.id,
      content_type: payload.model,
    },
  });
});

Webhook Testing Tools

1. Manual Testing with curl:

# Test webhook endpoint
curl -X POST https://your-site.com/api/webhook/strapi-tracking \
  -H "Content-Type: application/json" \
  -H "x-strapi-signature: your-signature" \
  -d '{
    "event": "entry.publish",
    "model": "article",
    "entry": {
      "id": 1,
      "title": "Test Article",
      "slug": "test-article"
    }
  }'

2. Webhook Testing Service:

Use webhook.site to capture webhook payloads:

// In Strapi webhook configuration temporarily
URL: https://webhook.site/your-unique-id

This shows you exactly what Strapi sends.

3. Local Tunnel for Development:

# Use ngrok to expose localhost
ngrok http 3000

# Use the ngrok URL in Strapi webhook
# Example: https://abc123.ngrok.io/api/webhook/strapi-tracking

4. Webhook Logging Middleware:

// middleware.ts
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/webhook')) {
    console.log('Webhook request received:');
    console.log('- Method:', request.method);
    console.log('- URL:', request.url);
    console.log('- Headers:', Object.fromEntries(request.headers));
    // Body is read in the route handler
  }

  return NextResponse.next();
}

Self-Hosted vs Cloud Strapi Considerations

Tracking implementation differs between self-hosted and Strapi Cloud deployments.

Self-Hosted Strapi

Advantages:

  • Full control over server configuration
  • No limitations on webhooks or plugins
  • Can customize Strapi core if needed
  • Direct access to server logs

Considerations:

1. CORS Configuration

// config/middlewares.js
module.exports = [
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'connect-src': ["'self'", 'https:', 'http:'],
          'script-src': [
            "'self'",
            "'unsafe-inline'",
            'https://www.googletagmanager.com',
            'https://www.google-analytics.com',
          ],
          'img-src': [
            "'self'",
            'data:',
            'blob:',
            'https://www.google-analytics.com',
            'https://www.googletagmanager.com',
          ],
        },
      },
    },
  },
  {
    name: 'strapi::cors',
    config: {
      enabled: true,
      headers: '*',
      origin: [
        'http://localhost:3000',
        'https://your-frontend.com',
        'https://www.googletagmanager.com',
        'https://www.google-analytics.com',
      ],
    },
  },
  // ... other middlewares
];

2. Environment Variables

# .env (Self-hosted Strapi)
HOST=0.0.0.0
PORT=1337
APP_KEYS=your-app-keys
API_TOKEN_SALT=your-salt
ADMIN_JWT_SECRET=your-admin-secret
JWT_SECRET=your-jwt-secret

# Frontend URL
FRONTEND_URL=https://your-frontend.com

# Webhook configuration
WEBHOOK_SECRET=your-webhook-secret
GA_MEASUREMENT_ID=G-XXXXXXXXXX
GA_API_SECRET=your-api-secret

# Database
DATABASE_CLIENT=postgres
DATABASE_HOST=your-db-host
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your-db-password

3. Network Configuration

If Strapi and Frontend on Different Servers:

// Frontend: .env.local
NEXT_PUBLIC_STRAPI_URL=https://api.yoursite.com
STRAPI_TOKEN=your-api-token

// Strapi webhook points to frontend
// Webhook URL: https://yoursite.com/api/webhook/strapi-tracking

If Strapi and Frontend on Same Server:

// Frontend: .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_TOKEN=your-api-token

// Webhook can use localhost
// Webhook URL: http://localhost:3000/api/webhook/strapi-tracking

4. SSL/TLS Configuration

# If using reverse proxy (nginx)
# /etc/nginx/sites-available/strapi

server {
    listen 443 ssl;
    server_name api.yoursite.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://localhost:1337;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

5. Performance Optimization

// config/server.js
module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  app: {
    keys: env.array('APP_KEYS'),
  },
  // Enable production optimizations
  admin: {
    url: '/admin',
    serveAdminPanel: env.bool('SERVE_ADMIN', true),
  },
  // Configure caching
  cache: {
    enabled: true,
    type: 'redis',
    options: {
      host: env('REDIS_HOST', 'localhost'),
      port: env.int('REDIS_PORT', 6379),
    },
  },
});

Strapi Cloud

Advantages:

  • Managed infrastructure
  • Automatic scaling
  • Built-in CDN
  • Automated backups

Considerations:

1. Environment Variables

Set via Strapi Cloud dashboard:

  • Settings → Variables
  • Add all tracking-related variables
# Set in Strapi Cloud dashboard
GA_MEASUREMENT_ID=G-XXXXXXXXXX
GA_API_SECRET=your-api-secret
WEBHOOK_SECRET=your-webhook-secret
FRONTEND_URL=https://your-frontend.com

2. Webhook Configuration

// Strapi Cloud webhooks must use HTTPS
// Webhook URL must be publicly accessible

// Settings → Webhooks
// URL: https://your-frontend.com/api/webhook/strapi-tracking
// Must use HTTPS, not HTTP

3. API Rate Limits

Strapi Cloud has rate limits:

// Implement rate limit handling in webhook
export async function POST(request: NextRequest) {
  try {
    const payload = await request.json();

    // Check rate limit header
    const remaining = request.headers.get('x-ratelimit-remaining');
    if (remaining && Number(remaining) < 10) {
      console.warn('Approaching Strapi Cloud rate limit');
    }

    // Process webhook
    await processWebhook(payload);

    return NextResponse.json({ success: true });
  } catch (error) {
    if (error.response?.status === 429) {
      // Rate limited
      console.error('Strapi Cloud rate limit exceeded');
      // Implement backoff or queue
    }

    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

4. CDN Considerations

// Strapi Cloud uses CDN for assets
// Ensure tracking scripts load from CDN

// Next.js config
module.exports = {
  images: {
    domains: [
      'your-strapi-cloud.strapiapp.com',
      'cdn.strapiapp.com',
    ],
  },
};

5. Custom Domain Setup

# In Strapi Cloud dashboard
# Settings → Domains
# Add custom domain: api.yoursite.com

# Update DNS records:
# CNAME: api.yoursite.com → your-project.strapiapp.com

Then update frontend:

# .env.production
NEXT_PUBLIC_STRAPI_URL=https://api.yoursite.com

Migration Between Self-Hosted and Cloud

Self-Hosted → Strapi Cloud:

# 1. Export data
npm run strapi export -- --file backup.tar.gz

# 2. Upload to Strapi Cloud
# Via dashboard or CLI

# 3. Update environment variables
# Frontend .env.production
NEXT_PUBLIC_STRAPI_URL=https://your-project.strapiapp.com

# 4. Update webhooks
# Change webhook URLs to use new Strapi Cloud URL

Strapi Cloud → Self-Hosted:

# 1. Export from Strapi Cloud
# Dashboard → Settings → Transfer

# 2. Import to self-hosted
npm run strapi import -- --file backup.tar.gz

# 3. Update environment variables
# Frontend .env.production
NEXT_PUBLIC_STRAPI_URL=https://api.yoursite.com

# 4. Configure server (nginx, SSL, etc.)
# 5. Update webhook URLs

Debugging by Deployment Type

Self-Hosted Debugging:

# Check Strapi logs
pm2 logs strapi
# or
docker logs strapi-container

# Check webhook endpoint
curl https://api.yoursite.com/api/articles

# Test webhook locally
ngrok http 1337

Strapi Cloud Debugging:

# Check logs in dashboard
# Settings → Logs

# Test webhook
curl https://your-project.strapiapp.com/api/articles \
  -H "Authorization: Bearer your-token"

# Use Strapi Cloud CLI
strapi logs --tail

Performance Comparison

Feature Self-Hosted Strapi Cloud
Setup time Hours-Days Minutes
Customization Full control Limited
Webhooks Unlimited Rate limited
Logging Direct access Dashboard only
Scaling Manual Automatic
Cost Server + Maintenance Subscription
SSL/HTTPS Manual setup Automatic
Backups Manual Automatic

Best Practices by Deployment Type

Self-Hosted:

  • Use PM2 or Docker for process management
  • Configure reverse proxy (nginx/Apache)
  • Set up automated backups
  • Monitor server resources
  • Implement rate limiting
  • Use Redis for caching

Strapi Cloud:

  • Use environment variables for all config
  • Monitor rate limits
  • Leverage built-in CDN
  • Use custom domain for production
  • Regular backups via dashboard
  • Monitor usage in dashboard

Next Steps

For general tracking troubleshooting, see Global Tracking Guide.