
Author Name
Emtiaz Hossain
iOS 14.5+ and browser tracking prevention quietly cut off a meaningful share of the Meta Pixel events a site used to see. The fix most people reach for is a paid server-side tagging tool. This is the alternative: implementing Meta's Conversions API directly from a custom Next.js server, no Stape, no third-party middleware, full control over what gets sent and when.
This walks through exactly how it's built, file by file.
Browser-side Meta Pixel was already running through GTM, firing standard events like PageView and Lead. That part isn't the focus here, it's the baseline CAPI needs to deduplicate against.
At the end of this, keep two values safe: the Pixel ID and the Access Token. Both are needed in the next step and neither should ever end up in client-side code.
File structure for a custom Next.js 13+ App Router setup:
src/
├── app/
│ └── api/
│ └── facebook-capi/
│ └── route.js
├── lib/
│ ├── facebookCapi.js
│ ├── hash.js
│ └── utils.js
├── utils/
│ └── getFbCookies.js
└── components/
├── Tracking/PageViewTracker.js
└── contact/ContactForm.jsxsrc/app/api/facebook-capi/route.jsimport { sendFacebookEvent } from "@/lib/facebookCapi";
import { getClientIp, getUserAgent, prepareUserData } from "@/lib/utils";
export async function POST(req) {
let eventName = "pageview";
try {
const body = await req.json();
const {
eventName: receivedEventName,
eventSourceUrl,
eventId,
eventTime,
...restOfBody
} = body;
if (!receivedEventName || !eventSourceUrl || !eventId || !eventTime) {
return new Response(
JSON.stringify({
error: "Missing required event parameters.",
received: body,
}),
{ status: 400 },
);
}
eventName = receivedEventName;
const userAgent = getUserAgent(req);
const clientIp = getClientIp(req);
const userData = prepareUserData(restOfBody, clientIp, userAgent);
const result = await sendFacebookEvent({
eventName,
eventId,
eventTime,
eventSourceUrl,
userData,
});
return new Response(JSON.stringify(result), { status: 200 });
} catch (err) {
console.error(`Facebook CAPI API Error - ${eventName}`, err);
let errorMessage = "An unknown error occurred.";
if (err instanceof SyntaxError && err.message.includes("JSON")) {
errorMessage = "Invalid JSON in request body.";
} else if (err instanceof Error) {
errorMessage = err.message;
}
return new Response(JSON.stringify({ error: errorMessage }), { status: 500 });
}
}src/lib/utils.jsimport { hash } from "./hash";
export function getClientIp(req) {
return (
req.headers.get("x-forwarded-for") ||
req.headers.get("x-real-ip") ||
"0.0.0.0"
);
}
export function getUserAgent(req) {
return req.headers.get("user-agent");
}
export function prepareUserData(body, clientIp, userAgent) {
const { city, state, zip, country, externalId, fbc, fbp, firstName, lastName, email, phone } = body;
const userData = {
client_ip_address: clientIp,
client_user_agent: userAgent,
fbc,
fbp,
};
if (email) userData.em = [hash(email)];
if (firstName) userData.fn = hash(firstName);
if (lastName) userData.ln = hash(lastName);
if (phone) userData.ph = [hash(phone)];
if (city) userData.ct = hash(city);
if (state) userData.st = hash(state);
if (zip) userData.zp = hash(zip);
if (country) userData.country = hash(country);
if (externalId) userData.external_id = hash(externalId?.toString());
return userData;
}src/lib/facebookCapi.jsThis sends the payload to {{https://graph.facebook.com/v18.0/{pixelId}}}/events.
export const sendFacebookEvent = async ({
eventName,
eventTime,
eventId,
eventSourceUrl,
userData = {},
}) => {
const pixelId = process.env.FB_PIXEL_ID;
const accessToken = process.env.FB_ACCESS_TOKEN;
if (!pixelId || !accessToken) {
throw new Error("Facebook Pixel ID or Access Token is missing from environment variables.");
}
const fbUrl = `{{https://graph.facebook.com/v18.0/${pixelId}}}/events?access_token=${accessToken}`;
const payload = {
data: [
{
event_name: eventName,
event_time: eventTime,
event_id: eventId,
event_source_url: eventSourceUrl,
action_source: "website",
user_data: userData,
},
],
};
try {
const fbRes = await fetch(fbUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await fbRes.json();
if (!fbRes.ok || result.error) {
console.error(`${eventName} API returned an error:`, result);
throw new Error(`Error: ${result.error?.message || "Unknown error"}`);
}
return result;
} catch (error) {
console.error(`Error - ${eventName}`, error);
throw error;
}
};The test_event_code trap. Meta's setup docs show test_event_code in every example because it's how you verify events in the Test Events tool before going live. If that parameter stays in the URL after testing, every event sent from this endpoint gets tagged as a test event permanently, it will show up in Meta's Test Events tab but will never count as a real conversion in Ads Manager, never feed optimization, and never show up in reporting. Remove it entirely once verification is done, don't leave it commented out and don't gate it behind an environment flag you might forget to unset.src/utils/getFbCookies.jsReads Meta's first-party cookies (_fbc, _fbp) client-side so they can be passed to the server for matching.
export const getFbCookies = () => {
if (typeof document === "undefined") return { fbc: "", fbp: "" };
const fbc =
document.cookie
.split("; ")
.find((row) => row.startsWith("_fbc="))
?.split("=")[1] || "";
const fbp =
document.cookie
.split("; ")
.find((row) => row.startsWith("_fbp="))
?.split("=")[1] || "";
return { fbc, fbp };
};src/lib/hash.jsMeta requires PII (email, phone, name, address fields) to be SHA-256 hashed before it's sent, never send these raw.
import crypto from "crypto";
export const hash = (val) => {
if (!val) return undefined;
return crypto
.createHash("sha256")
.update(val.trim().toLowerCase())
.digest("hex");
};Two components call the API route when events actually happen.
src/components/Tracking/PageViewTracker.jsFires a server-side PageView on mount.
"use client";
import { getEventId, getEventTime, getExternalId } from "@/lib/customID";
import { getGeoFromLocal } from "@/lib/getGeoFromLocal";
import { getFbCookies } from "@/utils/getFbCookies";
import { useEffect } from "react";
export default function PageViewTracker() {
useEffect(() => {
const { fbc, fbp } = getFbCookies();
const geo = getGeoFromLocal();
const payload = {
eventName: "PageView",
eventSourceUrl: window.location.href,
eventId: getEventId("pageview"),
eventTime: getEventTime(),
externalId: getExternalId(),
fbc,
fbp,
city: geo?.city || "",
state: geo?.region || "",
zip: geo?.postal || "",
country: geo?.country || "",
};
fetch("/api/facebook-capi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch((error) => {
console.error("Facebook CAPI PageView error:", error);
});
}, []);
return null;
}src/components/contact/ContactForm.jsxFires a Lead event on form submission, using the same eventId the browser Pixel uses so Meta can deduplicate the browser and server events instead of double-counting.
const contactPayload = {
eventName: "Lead",
eventSourceUrl: window.location.href,
eventId: getEventId("lead"),
eventTime: getEventTime(),
externalId: getExternalId(),
firstName,
lastName,
email,
phone,
city: geo?.city || "Unknown",
state: geo?.region || "Unknown",
zip: geo?.postal || "Unknown",
country: geo?.country || "Unknown",
fbc,
fbp,
};
fetch("/api/facebook-capi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(contactPayload),
});The same pattern extends to any conversion point, a Calendly booking form uses an identical payload shape with eventName: "Schedule".
FB_PIXEL_ID=your_pixel_id
FB_ACCESS_TOKEN=your_long_lived_tokenKeep the access token server-side only. It should never appear in a client bundle.
[Screenshot: Meta Events Manager showing server events matched with EMQ score]
eventId on both the Pixel and CAPI calls for the same user action, or Meta will count it twicetest_event_code before going live, it silently prevents every event from counting as a real conversionYes. CAPI is meant to run alongside the browser Pixel, not replace it. Meta deduplicates matching events between the two using a shared event_id, so you get the browser signal when it's available and the server-side signal to cover what ad blockers and Safari's ITP strip out. Sending CAPI only, with no Pixel, means losing real-time browser signals like scroll and click behavior that Meta also uses for optimization.
EMQ measures how much reliable customer data (email, phone, external ID) is attached to each event, not whether the event fired. A tag that fires perfectly but sends only an IP address and user agent will still score low. Hash the available PII fields (SHA-256) and pass as many matching parameters as you legitimately have, that's what moves the score.
Every event carrying that code gets routed to Meta's Test Events tool and excluded from campaign optimization and reporting, permanently, until removed. This is one of the most common CAPI mistakes: a developer sets it during setup, ships it, and Meta quietly stops learning from real conversions while the dashboard shows nothing obviously wrong.
Check Meta's Events Manager under the Test Events or Diagnostics view for that event name. Correctly deduplicated events show a single combined entry with both browser and server sources listed. If you see two separate event counts for the same action, the event_id isn't matching between your Pixel and CAPI calls.
Discussed this on LinkedIn
Found this helpful?
Drop a comment on the LinkedIn post – I read every reply and love connecting with tracking folks.
Book a free 30-minute call. I will review your current tracking setup, show you where conversions are leaking, and tell you exactly what I would fix.
A full GA4/GTM/Google Ads audit on a UK Shopify store spending £18,000+/month found duplicate conversion tags inflating reported purchases 2x, a dropped GCLID at checkout, and misconfigured variables silently degrading Enhanced Conversions. Here's the audit, the fixes, and the migration off a legacy GTM setup before Shopify's deprecation deadline.
How a UK marketing agency's LinkedIn Insight Tag upgrade turned into a full conversion audit, catching duplicate tags and stale triggers across eight lead-gen conversions.
Duplicate form conversions in GA4 are usually a reload firing the same tag twice, not a real second submission. Here's the Custom JavaScript variable that catches it, plus other common causes to rule out.