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.dataLayeris 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.datais null/undefinedauthor.datais 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
- In GTM, click Preview
- Enter your site URL
- Check which tags fire
- Inspect data layer values
- 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.