A marketing team asks you a simple question: which campaign produced the twelve leads that came in last week? You open the database. Every lead has a name, an email, a message, and a timestamp. Nothing about where the person came from. The ad platform says it drove forty clicks. Analytics says eight form submissions. Nobody can connect a single row in your leads table to a single ad.
This is fixable in an afternoon, and the fix lives in your codebase, not in a marketing tool. The plan: persist the click identifiers and UTM parameters the moment a visitor lands, attach them to the lead when the form posts, and send the conversion event from the server so it survives ad blockers and abandoned tabs.
Why client-side tracking alone misses leads
The usual setup fires a conversion from a gtag call on the thank you page. It works until it does not. Content blockers strip the tag. The visitor closes the tab before the redirect finishes. The form posts to an API route and the page never changes. Each of those cases is a real lead the ad platform never hears about, which means the bidding algorithm learns from an incomplete picture.
Server-side events solve the delivery problem. The attribution problem is separate: you still need to know which click the lead belongs to. Google Ads appends a gclid parameter to the landing URL on every click, and Meta, Microsoft and others do the same with their own identifiers. Capture it on first touch, keep it for the length of the buying cycle, and write it to the lead. The same approach covers referrals from AI assistants, which matters now that GA4 separates AI traffic into its own channel: a lead that arrived from a chat answer should say so in your database, not only in a report.
Step one: persist attribution in middleware
Next.js middleware runs on every request before rendering, which makes it the right place to read query parameters and set cookies without touching page components.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export const ATTR_KEYS = [
'gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid',
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
]
export function middleware(req: NextRequest) {
const res = NextResponse.next()
const params = req.nextUrl.searchParams
for (const key of ATTR_KEYS) {
const value = params.get(key)
if (!value) continue
res.cookies.set(`attr_${key}`, value.slice(0, 200), {
maxAge: 60 * 60 * 24 * 90,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
})
}
if (params.toString().length > 0 && !req.cookies.get('attr_landing')) {
res.cookies.set('attr_landing', req.nextUrl.pathname, {
maxAge: 60 * 60 * 24 * 90,
sameSite: 'lax',
path: '/',
})
}
return res
}
export const config = {
matcher: ['/((?!_next|api|.*\\..*).*)'],
}
Two decisions are baked in here. Ninety days covers most service business buying cycles, where someone clicks an ad in March and books in May. Last touch wins for the click identifiers, because that is how the ad platforms attribute by default and you want your numbers to match theirs. If you need first touch as well, store it under a second cookie prefix and skip the write when it already exists, the same way attr_landing works above.
If your site has to honor a consent banner, gate the cookies.set calls behind the consent cookie. The attribution still works for everyone who accepts, and the code path stays the same.
Step two: attach attribution when the lead posts
The form posts to a route handler. Read the cookies there, write them to the lead row, and you have a permanent record of the source for every lead, independent of whatever the analytics tool decides to keep.
// app/api/lead/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { ATTR_KEYS } from '@/middleware'
import { db } from '@/lib/db'
function gaClientId(raw?: string) {
// _ga looks like GA1.1.1234567890.1700000000
const parts = raw?.split('.') ?? []
return parts.length >= 4 ? `${parts[2]}.${parts[3]}` : undefined
}
export async function POST(req: Request) {
const body = await req.json()
const jar = await cookies()
const attribution = Object.fromEntries(
ATTR_KEYS.map((k) => [k, jar.get(`attr_${k}`)?.value ?? null]),
)
const clientId = gaClientId(jar.get('_ga')?.value) ?? crypto.randomUUID()
const lead = await db.lead.create({
data: {
name: body.name,
email: body.email,
message: body.message,
landingPath: jar.get('attr_landing')?.value ?? null,
gaClientId: clientId,
...attribution,
},
})
await sendServerEvent(clientId, lead.id, attribution)
return NextResponse.json({ ok: true, id: lead.id })
}
The _ga cookie is the piece most people skip. Its last two segments are the GA4 client ID for that browser. Sending the server event with the same client ID lets GA4 stitch the lead to the session that produced it, so the event inherits the session's source, medium and campaign. Without it, the event still records, but as a brand new user with no origin.
Step three: send the conversion from the server
GA4's Measurement Protocol accepts events over plain HTTPS. Create an API secret under the data stream settings, store it with the measurement ID in environment variables, and post the event.
// lib/ga.ts
export async function sendServerEvent(
clientId: string,
leadId: string,
attribution: Record<string, string | null>,
) {
const endpoint = new URL('https://www.google-analytics.com/mp/collect')
endpoint.searchParams.set('measurement_id', process.env.GA_MEASUREMENT_ID!)
endpoint.searchParams.set('api_secret', process.env.GA_API_SECRET!)
const res = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({
client_id: clientId,
events: [
{
name: 'generate_lead',
params: {
lead_id: leadId,
lead_source:
attribution.utm_source ??
(attribution.gclid || attribution.gbraid || attribution.wbraid
? 'google_ads'
: 'direct'),
has_click_id: Boolean(attribution.gclid || attribution.fbclid || attribution.msclkid),
},
},
],
}),
})
if (!res.ok) console.error('GA4 MP rejected event', res.status)
}
While you are building, swap /mp/collect for /debug/mp/collect. The debug endpoint returns validation messages instead of a silent 204, which saves a lot of guessing about field names.
Then do the two things that make the event useful to the people buying the ads. In GA4, mark generate_lead as a key event. In Google Ads, with the GA4 property linked, import that key event as a conversion. From that point the ad platform optimizes against leads that actually reached your database, not against page loads.
What this gives the business
Every lead row now answers the question the marketing team asked at the start, and it answers it from your own database. You can group leads by campaign in SQL, compare cost per lead across channels with numbers you trust, and hand the sales team the context of how each person found you.
When we manage paid search for a service business, this attribution plumbing is the first thing we check, because a bidding algorithm fed by half the conversions will happily spend the whole budget on the wrong half.
The next step, once the data is clean, is routing: getting the lead to the right person within minutes instead of letting it sit in a table. That is a workflow problem more than a code problem, and lead routing is where most marketing stacks bottleneck. But none of it works until the lead knows where it came from. Start there.
Level Up Digital Marketing Group builds Next.js sites and marketing systems for service businesses in San Diego. More at levelupdigitalmarketinggroup.com.
Top comments (0)