On a bilingual B&B site running GA4 and Google Ads side by side, there is exactly one way for the data to go wrong that you won’t notice until it’s too late: an event fires into both destinations when it should only have fired into one. The GA4 behaviour stream picks up a phantom conversion. The Ads account learns from an event that was never a conversion. Nothing throws. Nothing looks broken. The numbers are just quietly fictional.
That failure mode is the entire reason this skill exists.
The system this belongs to
Each client B&B has two separate concerns living on the same page. GA4 tracks behaviour — page views, scroll depth, which contact buttons get tapped. Google Ads tracks conversions — the specific on-site CTA clicks (phone tap, LINE tap, book-now) that the ad account is allowed to optimize against. These are different questions answered by different systems, and they happen to share one gtag.js runtime.
The ads engine downstream only works if those two streams stay clean. If GA4’s behaviour events leak an Ads send_to, the Ads account starts bidding toward noise. If an Ads conversion accidentally lands in GA4, the weekly report’s intent numbers inflate. Both systems are making decisions — automated bidding on one side, operator-facing reporting on the other — and both decisions are only as good as the separation underneath.
The original implementation that proved this out runs on a bilingual Astro site where GA4 and Ads have coexisted cleanly for months. The skill is that implementation, generalized so the next site doesn’t have to rediscover the same traps.
The defining design choice
One gtag.js load. Then an explicit gtag('config', ...) call per destination — one for the GA4 property, one for the Ads account. And then the rule that does all the actual work: every event names its destination with send_to.
This is the part a less careful setup gets wrong. The default gtag('event', ...) with no send_to broadcasts to every configured destination. On a page with both GA4 and an Ads tag configured, an unscoped event goes to both. That’s how a scroll-depth event ends up as an Ads signal, or a button tap meant only for conversion measurement also pads the GA4 behaviour stream.
The skill enforces the opposite by construction. GA4 behaviour events carry a GA4-only send_to and live in their own DOMContentLoaded handler. Ads conversions live in a completely separate click handler, fire gtag('event', 'conversion', ...) with a real per-channel conversion label, and never touch the GA4 path. The two never share a code path, which means they can’t accidentally share a destination.
The config module is a single source of truth: GA4 IDs always, the GOOGLE_ADS_CONFIG block only when the project actually runs ads.
Google Ads is optional — and that branch is everything
The most important decision in the whole skill is made in step two and carried through every later step: does this project run Google Ads at all?
Many of these sites run GA4 only. For those, writing any Ads wiring is worse than useless — it’s a liability. The skill makes the optional branch explicit and adds a hard rule: never write a Google Ads conversion label the operator didn’t give you. Placeholders are forbidden.
That “no placeholders” rule sounds pedantic until you’ve seen what a placeholder conversion label does. A fake label doesn’t error. It silently fires conversions against nothing, or worse, against a label that belongs to a different account. By the time anyone notices, the Ads account has been optimizing against a ghost for weeks. Forbidding placeholders outright is cheaper than any amount of later debugging.
The same discipline runs through the onboarding side. The new-site skill that wires a client into the analysis engine carries the same line: every ID, label, email, and URL in the final files must be real — collect the value before writing the file, never after.
Why a skill, not a snippet
Four reference templates ship with the prompt: the config module, the <head> loader, the GA4 behaviour handler, and the Ads conversion handler. They’re written for Astro, but the prompt is explicit that the mechanism — config module, a script in <head>, a DOMContentLoaded handler — transfers unchanged to Next.js <Script>, plain HTML, or anything that can inline a script in the head. Only the host framework changes; the logic doesn’t.
The reason to package this as a skill rather than copy-paste a snippet is the part that isn’t code: the decision tree. Is this an Ads project or not? Which events get a GA4 send_to versus an Ads conversion label? Where are the existing stray gtag / dataLayer references that will collide if you don’t grep for them first? Those are the questions a fresh setup gets wrong, and a snippet doesn’t answer them. The skill encodes the judgment, not just the wiring.
What this is part of
This is one node in a larger digital operations stack for a set of Hengchun B&Bs — sites that own their own bookings, a weekly automated report, and an ads engine that optimizes against real signals. The tracking layer is what makes the rest measurable. Without clean separation between behaviour and conversion data, every downstream number is suspect: the weekly report can’t tell intent from noise, and the ads engine can’t tell a conversion from a scroll.
For a small B&B, paying a specialist to set this up correctly per site was never economically realistic — the per-site cost would have swallowed the budget. Encoding the setup, the traps, and the decision tree into a skill is what makes correct tracking affordable to deploy across every property instead of just the one that justified the original engineering.
The separation is invisible when it works, which is exactly why it has to be enforced by construction rather than by remembering to be careful.