First-Party Tracking with Cloudflare Pages
Set up a first-party proxy using Cloudflare Pages Functions. This is the best option if you're already hosting your site on Cloudflare Pages.
Difficulty: Easy - Just add one file to your project. Takes about 5 minutes.
Why This Works (Same-Origin)
This approach uses /api/_z/script.js which is on the same origin as your website. Firefox ETP and other tracking protections only block cross-origin requests.
- Browser sees:
yourdomain.com/api/_z/script.js→ Same origin ✅ - Your function proxies to Zenovay server-to-server (browser never sees this)
- All tracking protection is bypassed because the request is first-party
The proxied script stops honouring Do Not Track
Every bundle served through /api/_z/script.js ships with ignoreDNT enabled, so a visitor whose browser sends the legacy Do Not Track signal is still tracked on a proxied site, whether or not you asked for that. Global Privacy Control is never overridden and is still honoured. If Do Not Track matters for your audience, serve the standard script from api.zenovay.com instead. See Privacy-first.
Before You Start
Make sure you have:
- A project deployed on Cloudflare Pages
- Your Zenovay tracking code (found in Domains, open your site, then the General tab — in the Tracking script card)
- Access to your project's source code
Your Tracking Code Format
Your tracking code looks like: ZV_XXXXXXXXXX
- Starts with
ZV_ - Followed by 10 characters (letters and numbers)
- CASE-SENSITIVE - copy it exactly
Example: ZV_Q8U0GYD70WR

How It Works
When you add a functions folder to your Cloudflare Pages project, Cloudflare automatically creates serverless functions. We'll create a function that proxies requests to Zenovay's API, making them appear as first-party requests to browsers.
Browser → yourdomain.pages.dev/api/_z/script.js ✅ (First-party)
↓
Cloudflare Function
↓
api.zenovay.com/fp/script.js (Server-to-server)
Step 1: Create the Functions Directory
File Location is CRITICAL
The functions folder MUST be at your project root. Not inside src/, not inside public/.
Correct location:
your-project/
├── functions/ ← HERE (project root)
│ └── api/
│ └── _z/
│ └── [[path]].ts
├── src/
├── public/
└── package.json
WRONG locations:
- ❌
src/functions/api/_z/[[path]].ts- Functions won't work - ❌
public/functions/api/_z/[[path]].ts- Functions won't work
Create the directory structure:
# Create the functions directory structure
mkdir -p functions/api/_z
# Verify you're in the right place
ls -la
# You should see: functions/ src/ public/ package.json etc.After running these commands, your project structure should look like this:
your-project/
├── functions/
│ └── api/
│ └── _z/
│ └── [[path]].ts ← We'll create this next
├── src/
├── public/
├── package.json
└── ...
Step 2: Create the Proxy Function
File Naming is IMPORTANT
The file MUST be named [[path]].ts (or [[path]].js for JavaScript):
- Two opening brackets:
[[ - The word
path - Two closing brackets:
]] - File extension:
.tsor.js
This creates a "catch-all" route that handles any path after /api/_z/.
TypeScript or JavaScript?
- Use
.tsif your project uses TypeScript - Use
.jsif your project uses JavaScript - Both work exactly the same way
If unsure, try .ts first. If you get TypeScript errors during build, rename it to .js instead.
Create the file functions/api/_z/[[path]].ts with this content:
/**
* Zenovay First-Party Proxy
* This function proxies tracking requests to make them first-party
*/
interface EventContext {
request: Request
params: { path?: string[] }
}
export async function onRequest(context: EventContext): Promise<Response> {
const { request, params } = context
// Build path from catch-all parameter
// [[path]] captures: script.js, e/CODE, settings/CODE, etc.
const path = (params.path || []).join('/')
const url = new URL(request.url)
const targetUrl = `https://api.zenovay.com/fp/${path}${url.search}`
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}
try {
// Get the real client IP for accurate geolocation
const clientIP = request.headers.get('CF-Connecting-IP') || ''
// Forward request with real IP
const proxyHeaders = new Headers(request.headers)
proxyHeaders.set('X-Zenovay-Real-IP', clientIP)
proxyHeaders.delete('Host')
// Build the request
const requestInit: RequestInit = {
method: request.method,
headers: proxyHeaders,
}
// Include body for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
requestInit.body = await request.arrayBuffer()
}
// Forward to Zenovay
const response = await fetch(targetUrl, requestInit)
// Add CORS headers to response
const responseHeaders = new Headers(response.headers)
responseHeaders.set('Access-Control-Allow-Origin', '*')
return new Response(response.body, {
status: response.status,
headers: responseHeaders,
})
} catch (error) {
console.error('Proxy error:', error)
return new Response(JSON.stringify({ error: 'Proxy error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
})
}
}JavaScript Alternative
If you prefer JavaScript (no TypeScript), create functions/api/_z/[[path]].js:
/**
* Zenovay First-Party Proxy
* This function proxies tracking requests to make them first-party
*/
export async function onRequest(context) {
const { request, params } = context
// Build path from catch-all parameter
const path = (params.path || []).join('/')
const url = new URL(request.url)
const targetUrl = `https://api.zenovay.com/fp/${path}${url.search}`
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}
try {
// Get the real client IP for accurate geolocation
const clientIP = request.headers.get('CF-Connecting-IP') || ''
// Forward request with real IP
const headers = new Headers(request.headers)
headers.set('X-Zenovay-Real-IP', clientIP)
headers.delete('Host')
// Forward to Zenovay
const response = await fetch(targetUrl, {
method: request.method,
headers: headers,
body: ['POST', 'PUT', 'PATCH'].includes(request.method)
? await request.arrayBuffer()
: undefined,
})
// Add CORS headers to response
const responseHeaders = new Headers(response.headers)
responseHeaders.set('Access-Control-Allow-Origin', '*')
return new Response(response.body, {
status: response.status,
headers: responseHeaders,
})
} catch (error) {
return new Response(JSON.stringify({ error: 'Proxy error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
})
}
}Step 3: Add the Tracking Script
Add this script to your website's HTML. The location depends on your framework:
<!-- Zenovay Analytics - First-Party Tracking -->
<script defer
data-tracking-code="YOUR_TRACKING_CODE"
src="/api/_z/script.js">
</script>Replace YOUR_TRACKING_CODE with your actual tracking code from the Zenovay dashboard (e.g., ZV_Q8U0GYD70WR).
Framework-Specific Examples
React / Next.js
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<script
defer
data-tracking-code="YOUR_TRACKING_CODE"
src="/api/_z/script.js"
/>
</head>
<body>{children}</body>
</html>
)
}Vue / Nuxt
<!-- In app.vue -->
<script setup>
useHead({
script: [
{
src: '/api/_z/script.js',
defer: true,
'data-tracking-code': 'YOUR_TRACKING_CODE'
}
]
})
</script>Astro
---
const { title } = Astro.props;
---
<html lang="en">
<head>
<title>{title}</title>
<script defer data-tracking-code="YOUR_TRACKING_CODE" src="/api/_z/script.js"></script>
</head>
<body>
<slot />
</body>
</html>Plain HTML
<!DOCTYPE html>
<html>
<head>
<title>My Website</title>
<script defer data-tracking-code="YOUR_TRACKING_CODE" src="/api/_z/script.js"></script>
</head>
<body>
<!-- Your content -->
</body>
</html>Step 4: Deploy
Deploy your project to Cloudflare Pages:
Option A: Using Git (Recommended)
If you have automatic deployments from Git:
git add .
git commit -m "Add Zenovay first-party tracking"
git pushCloudflare Pages will automatically build and deploy.
Option B: Using Wrangler CLI
# Build your project first
npm run build
# Deploy to Cloudflare Pages
npx wrangler pages deploy ./dist --project-name=your-project-nameReplace ./dist with your build output directory (could be ./out, ./build, etc.) and your-project-name with your Cloudflare Pages project name.
Step 5: Verify It's Working
Check 1: Functions Tab in Cloudflare
- Go to Cloudflare Dashboard → Pages
- Click on your project
- Click the Functions tab
- You should see
api/_z/[[path]]listed
Don't see the function?
- Make sure
functions/folder is at project root (not insidesrc/) - Make sure the file is named
[[path]].tsor[[path]].js - Check that the deployment completed successfully
Check 2: Network Tab in Browser
- Open your deployed site
- Press F12 (or Cmd+Option+I on Mac) to open DevTools
- Click the Network tab
- Refresh the page (Cmd+R or Ctrl+R)
- In the filter box, type
script.js - Look for
/api/_z/script.js
What you should see:
- Status: 200 ✅
- Domain: Your Pages domain (e.g.,
your-project.pages.dev) - Response: JavaScript code
Check 3: Firefox Strict Mode (Most Important!)
Firefox has the strictest tracking protection. If it works in Firefox, it works everywhere.
- Open Firefox browser
- Click the menu (☰) → Settings
- Click Privacy & Security in the left sidebar
- Under "Enhanced Tracking Protection", select Strict
- Visit your website
- Open DevTools (F12) → Network tab
- Refresh and verify
/api/_z/script.jsloads with status 200
Check 4: Zenovay Dashboard
- Go to app.zenovay.com and log in
- Click on your website
- Visit your deployed site in another tab
- Within 1-2 minutes, you should see the visit appear in your dashboard
Final Checklist
Before you're done, verify ALL of these:
-
functions/api/_z/[[path]].tsfile exists at project root - Function appears in Cloudflare Pages Functions tab
- Script loads at
/api/_z/script.jswith status 200 -
data-tracking-codeattribute contains your correct tracking code - Tested in Firefox with Enhanced Tracking Protection set to Strict
- Visits appearing in Zenovay dashboard
Troubleshooting
Function Returns 404
Cause: The function file is in the wrong location or has the wrong name.
Solution:
- Verify the file is at exactly
functions/api/_z/[[path]].tsfunctionsfolder must be at project root- Must have
api/_z/subdirectories - Must be named
[[path]].tswith double brackets
- Make sure the
functionsfolder is included in your deployment - Check the Functions tab in Cloudflare dashboard
Function Returns 500
Cause: There's a syntax error or runtime error in the function code.
Solution:
- Check the Functions logs in Cloudflare for error details:
- Go to your Pages project → Functions → Logs
- Verify all the code was copied correctly
- Make sure you're exporting
onRequest(notexport default)
TypeScript Errors During Build
Cause: Missing type definitions.
Solution: Option 1: Install Cloudflare Workers types:
npm install --save-dev @cloudflare/workers-typesOption 2: Use the JavaScript version instead (rename to [[path]].js)
CORS Errors in Console
Cause: CORS headers aren't being added correctly.
Solution: Make sure your function includes both:
- The OPTIONS handler for preflight requests
responseHeaders.set('Access-Control-Allow-Origin', '*')in the response
Geolocation is Wrong
Cause: Client IP not being forwarded.
Solution: Verify your function includes:
const clientIP = request.headers.get('CF-Connecting-IP') || ''
proxyHeaders.set('X-Zenovay-Real-IP', clientIP)
Script Loads But No Data in Dashboard
Cause: Tracking code mismatch.
Solution:
- Open DevTools Console (F12 → Console tab) and look for errors
- Verify your
data-tracking-codematches exactly what's in your Zenovay dashboard (case-sensitive!) - Make sure the domain is registered in Zenovay
Next Steps
- Custom Events - Track user interactions
- Visitor Identification - Link analytics to users
- Troubleshooting - More help with issues
On this page