Two Spouts
The Renaissance Marketer
Chapter 13 of 15

Technical Marketing

"In God we trust. All others must bring data." — W. Edwards Deming

This is the chapter where we stop talking and start typing. Most marketers treat tracking as a developer thing — "just put it in Tag Manager." Nope. You're going to own it, in code, in your own repo, where you can read it, version it, and fix it. Why it matters: every decision rests on your numbers. Broken tracking is the single biggest place I see solo marketers quietly leak money — a pixel firing on the wrong page for six months, a purchase counted twice, UTMs that look like a toddler typed them.

So this is hands-on. Real snippets you paste into a real Next.js site. By the end you'll have a pixel, a conversion event, form-fill tracking, and a cookie-based offer — all hard-coded, all yours.

Before you spend a single dollar on traffic, prove a conversion actually fires where you think it does. Open the Network tab, submit your own form, watch the request leave. If you can't see it, the platform can't either.

Technical Marketing — gif

I don't use GA4. Here's what I do instead

Let me be blunt: I don't use GA4 and I don't recommend it. The interface is a maze, "explorations" take six clicks to answer a one-click question, the data is sampled and delayed, and Google's whole incentive is to keep your data inside its walled garden. I find it genuinely painful.

Instead I use a lightweight, privacy-friendly analytics tool: visitors, pageviews, sources, and top pages in one clean dashboard, plus my own hard-coded conversion events for the things that actually move money. I don't need a 40-tab interface to tell me which page gets traffic and whether the form got submitted.

Plausible (or any lightweight, cookie-free analytics). One script, no cookie banner, no GA4 PhD required — it does the 90% of analytics you actually look at, and it's fast. See visitors / top sources / top pages at a glance, fire custom goals ("Signup", "Lead") with one line of JS, skip the cookie banner entirely. Drop it in your app root and you're done:
// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script
          defer
          data-domain="yoursite.com"
          src="https://plausible.io/js/script.js"
          strategy="afterInteractive"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Not religious about Plausible specifically — Fathom, Umami, or a self-hosted instance all do the same job. The point: a small, fast tool you understand beats a giant one you fight. If GA4 sparks joy for you, fine. It doesn't for me.

I quit Google Tag Manager. Yes, really

I used to love GTM. One container on the site, then you manage every pixel and tag from a web dashboard — no dev, no deploy, ship a tracking change in minutes. For years that was the right call. I don't use it anymore. What changed: AI. The whole point of GTM was avoiding the codebase, but I'm already in the codebase — my site is Next.js, I deploy in 30 seconds, and AI writes the tracking snippet faster than I could click through GTM's UI and configure a trigger. So that abstraction layer is now pure overhead: another login, another place tags can double-fire, another dependency in the browser.

So now I hard-code tracking directly into the site. Faster to set up, it lives in git next to everything else, I can read exactly what fires and when, and there's no mystery container someone else edited. If you own your code, GTM is solving a problem you no longer have.

If you're on a platform you DON'T control — a client's WordPress site, a no-code builder — GTM still earns its keep. The "just hard-code it" advice assumes you own the repo. If you don't, GTM is your way in.

Hard-code the Meta Pixel

A pixel is a snippet that tells a platform "someone did a thing on my site" so it can optimise your ads and build audiences. People treat it as scary GTM territory. It's just a script tag. Here's Meta's base pixel, hard-coded with a Next.js Script component — render it once in your layout and it tracks PageView on every route, making fbq() globally available so you can fire custom events anywhere (exactly what we do for conversions next):

// components/MetaPixel.tsx
"use client";
import Script from "next/script";

const PIXEL_ID = "123456789012345"; // your pixel ID

export default function MetaPixel() {
  return (
    <Script id="meta-pixel" strategy="afterInteractive">
      {`
        !function(f,b,e,v,n,t,s)
        {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
        n.callMethod.apply(n,arguments):n.queue.push(arguments)};
        if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
        n.queue=[];t=b.createElement(e);t.async=!0;
        t.src=v;s=b.getElementsByTagName(e)[0];
        s.parentNode.insertBefore(t,s)}(window, document,'script',
        'https://connect.facebook.net/en_US/fbevents.js');
        fbq('init', '${PIXEL_ID}');
        fbq('track', 'PageView');
      `}
    </Script>
  );
}

The double-fire trap is real: never put the same pixel in code AND in GTM (or in two components). Pick one home. Hard-coded is mine. If your event count is mysteriously doubled, this is almost always why.

Hard-code the Google Ads conversion (gtag)

Google Ads conversions use gtag. Two parts: load the gtag library once in your layout, then fire a conversion event at the moment the conversion happens.

// components/GoogleAds.tsx — load gtag once in your layout
import Script from "next/script";

const AW_ID = "AW-123456789";

export default function GoogleAds() {
  return (
    <>
      <Script src={`https://www.googletagmanager.com/gtag/js?id=${AW_ID}`} strategy="afterInteractive" />
      <Script id="gtag-init" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', '${AW_ID}');
        `}
      </Script>
    </>
  );
}

// then fire the conversion at the exact moment it happens (thank-you page or
// right after a successful submit) — pass value + currency so ROAS isn't a guess
window.gtag?.("event", "conversion", {
  send_to: "AW-123456789/AbC-D_efGhIjKlMnOp", // conversion label from Google Ads
  value: 49.0,
  currency: "USD",
  transaction_id: orderId, // dedupes if the page reloads
});

Always pass transaction_id. It's how Google dedupes a conversion if the user refreshes the thank-you page. No transaction_id = one sale counted three times = a ROAS number that lies to you.

Track the form fill (fire a conversion on submit)

For lead gen, the conversion IS the form submit. Fire the event client-side at submit time — capture the lead AND tell the ad platforms it happened, in the same handler. No thank-you page required. One handler, three jobs: store the lead, fire every conversion that matters, redirect — and because it's your code, you can SEE it fires exactly once:

// components/LeadForm.tsx
"use client";
import { useState } from "react";

export default function LeadForm() {
  const [email, setEmail] = useState("");

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    // 1. store the lead (your own API → DB)
    await fetch("/api/lead", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });

    // 2. fire the conversions client-side
    window.fbq?.("track", "Lead");
    window.gtag?.("event", "conversion", { send_to: "AW-123456789/AbC-D_efGhIjKlMnOp" });
    window.plausible?.("Lead"); // custom goal in Plausible

    // 3. send them somewhere nice
    window.location.href = "/thanks";
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" required value={email}
        onChange={(e) => setEmail(e.target.value)} placeholder="you@email.com" />
      <button type="submit">Get the guide</button>
    </form>
  );
}

Fire the conversion AFTER the lead is actually stored, not before. If your API call fails, you don't want to report conversions you never captured. Order matters: save first, then track.

Cookie-based offers

A fun one that's pure marketing leverage: use a cookie to show, persist, or hide an offer. Classic case — someone lands from a campaign, you give them a discount, and the cookie keeps it alive across their whole visit (and the next) even after they leave the landing page. Set it when they arrive with the right UTM, then read it anywhere to show the banner or pre-fill the discount at checkout:

// app/offer/page.tsx — visitor lands from ?utm_campaign=launch
"use client";
import { useEffect } from "react";

export default function OfferPage() {
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    if (params.get("utm_campaign") === "launch") {
      document.cookie = // 7-day discount cookie
        "offer=LAUNCH20; path=/; max-age=" + 60 * 60 * 24 * 7 + "; SameSite=Lax";
    }
  }, []);
  return <h1>Welcome — your 20% code is locked in.</h1>;
}

// components/OfferBanner.tsx — read it anywhere to show the banner
"use client";
import { useEffect, useState } from "react";

function getCookie(name: string) {
  return document.cookie.split("; ")
    .find((row) => row.startsWith(name + "="))?.split("=")[1];
}

export default function OfferBanner() {
  const [code, setCode] = useState<string | null>(null);
  useEffect(() => setCode(getCookie("offer") ?? null), []);
  if (!code) return null;
  return <div className="offer-bar">🎉 Your code <strong>{code}</strong> is applied — 20% off, this week only.</div>;
}

Cookies make a site feel personal without a heavy CRM: discount on arrival, persistent referral source, "welcome back" for returning visitors, hide the popup for people who already signed up. Small touches, real conversion lift, ~15 lines each.

UTMs done right (and owned)

A UTM is a tag you bolt onto a URL so analytics knows where a click came from. Five parameters: source (where), medium (the type of traffic), campaign (the name), content (split variants), term (keyword).

https://yoursite.com/pricing
  ?utm_source=newsletter
  &utm_medium=email
  &utm_campaign=june-launch
  &utm_content=header-cta

Sounds simple — yet I've audited accounts where the same campaign was tagged Facebook, facebook, FB, and fb: four "different" sources. Your reports are now lying and you don't know it.

UTMs are case-sensitive. Analytics treats facebook and Facebook as two different sources. Pick lowercase, no spaces, hyphens-not-underscores, and NEVER deviate. Boring consistency beats clever every time.

Don't hand-type these — you'll fat-finger it. Since we own our tracking, own UTM generation too. A tiny helper makes every link consistent by construction:

// lib/utm.ts
type UTM = { source: string; medium: string; campaign: string; content?: string; term?: string };

export function buildUrl(base: string, utm: UTM) {
  const p = new URLSearchParams();
  for (const [k, v] of Object.entries(utm)) {
    if (v) p.set(`utm_${k}`, v.toLowerCase().trim().replace(/\s+/g, "-"));
  }
  return `${base}?${p.toString()}`;
}
// → .../pricing?utm_source=newsletter&utm_medium=email&utm_campaign=june-launch

Lowercasing and de-spacing happen automatically, so the four-spellings-of-Facebook problem can't occur. UTMs matter for any click leaving a platform that doesn't auto-tag — email, social posts, sponsorships, QR codes, partner links. Google Ads auto-tags itself, so don't double-tag it.

Capture UTMs into your lead record

Tracking the click is half of it. The other half: when that visitor becomes a lead, attach the UTMs so you know which campaign produced which customer — not just which produced traffic. Grab them on landing, stash in a cookie, attach at submit:

// read UTMs from the URL, persist them, send with the lead
function captureUtms() {
  const p = new URLSearchParams(window.location.search);
  const utms: Record<string, string> = {};
  ["source", "medium", "campaign", "content", "term"].forEach((k) => {
    const v = p.get(`utm_${k}`);
    if (v) utms[k] = v;
  });
  if (Object.keys(utms).length) {
    document.cookie = "utms=" + encodeURIComponent(JSON.stringify(utms)) +
      "; path=/; max-age=" + 60 * 60 * 24 * 30 + "; SameSite=Lax";
  }
}
// then in your form submit, read the cookie and POST it with the email

This is the payoff for owning your tracking. The lead lands in YOUR database with its source attached. No GA4 export, no attribution black box — you can literally query "which campaign produced paying customers" in SQL. That's leverage GA4 will never give you cleanly.

Pixel + Conversion API, briefly

Pixels fire in the browser, so ad blockers, iOS privacy, and dying third-party cookies eat a chunk of them. The fix is the Conversion API (CAPI) — a server-to-server send from your backend straight to the platform. Meta calls it Conversions API, Google has Enhanced Conversions, LinkedIn has its own. The modern setup is BOTH — pixel in the browser plus a server send, deduplicated by event ID so one purchase isn't counted twice. Since you already own your /api/lead route, the server side is just one more fetch with the same event ID.

With cookies dying, CAPI / Enhanced Conversions isn't a nice-to-have — platforms literally optimise worse without it. Better data in, cheaper conversions out. Only bother for platforms you actually advertise on — don't blanket-install every pixel on day one, or you're slowing your site and leaking user data for nothing. Lean, remember.

Attribution — what to trust, what to ignore

Attribution asks "which touchpoint gets credit for the sale?" Straight talk: it's mostly a polite fiction. The customer saw your ad, read a blog, got an email, googled you, THEN bought. Who gets credit?

  • Last-click — all credit to the final touch. Overrates bottom-funnel stuff like branded search.
  • First-click — all credit to the first touch. Overrates top-funnel discovery.
  • Linear / time-decay — spread the credit. More honest, harder to action.
  • Data-driven — an algorithm assigns credit. Best of the bunch, but a black box, so trust it loosely.

What I actually trust: the overall trend, not the decimal points. Turn a channel off — if total revenue drops, that channel was doing something, regardless of what any report claimed. That's incrementality, and it beats every model.

Never make a kill-or-scale decision off a single attribution model — different models tell opposite stories about the same channel. Triangulate: incrementality plus self-reported attribution. The most underrated tool of all is the form field "How did you hear about us?" — it catches what every platform misses (word of mouth, that podcast you sponsored once), and since you own the form it costs you one extra column.

Debug your tracking (the half everyone skips)

Setting tracking up is half the job. Proving it works is the other half — and the half everyone skips.

Browser dev tools — the Network tab. This is your truth machine. Open dev tools, go to Network, filter by the platform, and watch requests fire as you click — no guessing, no mystery container. Filter "tr" to catch the Meta pixel (and confirm it fires ONCE), "collect"/"plausible" to see analytics events leave, and inspect the payload to confirm value, currency, and transaction_id are populated, not 0 or undefined. Also use each platform's test view: Meta Test Events, Google Ads conversion diagnostics, Plausible's real-time custom goals.
My pre-launch tracking checklist:
1. Convert myself in an incognito window
2. Watch the event fire ONCE in the Network tab
3. Confirm value + currency are correct (not 0, not undefined)
4. Confirm it lands in Meta Test Events / Google diagnostics
5. Wait 24-48h, confirm the count matches reality
6. Only THEN scale spend

Re-check tracking after ANY change — redesign, checkout swap, dependency bump. Tracking breaks silently; you won't get an error email, you'll just quietly stop counting and wonder why ROAS "tanked." Upside of hard-coded tracking: it's in git, so a broken change shows up in your diff. Still — put a monthly health check on the calendar.

Lead capture — custom forms, not builders

Avoid form builders at all costs. Typeform, the embedded HubSpot/Mailchimp widgets, all of it — slow, generic-looking, and they box you in. Custom-code your forms instead. It's a few lines now (ask Claude), and you get total flexibility — most importantly, hidden fields.

Hidden fields are where the money is. You capture the stuff the visitor never sees but you need:

  • UTMs — source / medium / campaign
  • gclid / fbclid for proper ad attribution
  • Referrer, landing page, and which variant they saw — literally anything, you control the whole payload
<input type="hidden" name="utm_source" value={utm.source} />
<input type="hidden" name="gclid" value={gclid} />
<input type="hidden" name="landing_page" value={path} />

Custom forms also mean you own the data — it goes straight into your own secure database, not trapped in some builder's dashboard you're renting.

For the alert, I send myself a Telegram message the second a lead lands. The Telegram Bot API is free, I see it on my phone instantly — no waiting on an email digest:

await fetch("https://api.telegram.org/bot" + process.env.TG_TOKEN + "/sendMessage", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ chat_id: process.env.TG_CHAT_ID, text: "New lead: " + email }),
});

A Telegram bot takes two minutes to set up (message @BotFather), costs nothing, and the API is dead simple. Beats paying for a notification SaaS. Own the plumbing and everything downstream gets easier: trustworthy reports, ad platforms that optimise properly, leads in your own database with their source attached. That's the whole game.

Sweet, you’ve completed this section! 🥳 Move on to the next section on marketing automation.