Websites
Vercel and GA4: publishing as commits, analytics after consent
Where this site runs and how I know what gets read: every admin save is one commit that Vercel deploys, GA4 loads only after consent, events fire only on real outcomes, and the numbers come back to my own admin, where they shape what I write next.

The easiest number to misread on this site is the newsletter one. The form answers "accepted" for a new address, a pending one, a confirmed one and a capped one alike, so nobody can probe which addresses are on the list. If the analytics event fired on that answer and I called it a signup, I would be counting something that never happened.
That small decision sums up what I wanted from hosting and analytics. A personal site needs two things from its host and its analytics: publishing should be one commit that deploys itself, and measurement should answer my own questions, after consent, with events that fire only on real outcomes, and with the numbers in my own admin, where they shape what I write next.
This is the companion to How I designed this site. That post covers the design. This one covers where the site runs and how I know what gets read.
The site is a Next.js app in the repo's web/ folder, deployed on Vercel. The setup is short: import the repo, set the root directory to web, keep the Next.js preset, and link the local checkout once.
vercel link
Every secret lives in Vercel's environment variables, added from the terminal so a value never sits in a file. To check which names exist without printing any value:
vercel env ls production
The functions sit next to the database. The database is Neon (Postgres) in Frankfurt, so the functions run in Vercel's Frankfurt region too. A page that reads view counts should not cross a continent to do it.
Every pull request gets a preview. Vercel builds each branch at its own URL, and the plan was that browser tests run again against that real preview before a merge, not only against a local build. A preview has no sign-in and no database, so anything that needs the admin is checked on production.
Saving in the admin is a commit, and this is the part I use every day. When I save, publish or upload an image in /admin on the live site, the server collects every file that action changes and sends them to GitHub as one commit on main. Vercel builds that commit, and the change is live in a minute or two. If the build fails, Vercel keeps serving the last good version.
That means there is no separate CMS database to back up or migrate. The content is Markdown in the repo, the history is git, and the admin is just a friendlier way to make commits. If two saves race, the server retries once on the new head and otherwise saves nothing, so an action is one commit or none.
Something broke on the way. The publish step runs a safety scan before anything goes public, and that scan fails closed (it refuses when it cannot run). On Vercel, the pattern file it reads was not bundled with the function, so live saves would have been refused. The fix was to bundle the content files and the safety patterns with those routes. Failing closed meant the bug blocked publishing instead of skipping the check.
One more thing is easy to miss: environment changes apply only to new deployments, and the GA4 measurement id is baked in at build time. Change it, and you redeploy:
vercel --prod
Collection uses Google Analytics (GA4), and it starts with a rule: nothing is sent before the visitor accepts the consent banner. The banner gives accept and decline equal weight. Consent Mode starts with everything denied, and the GA script itself is injected only after consent.
The gate lives in memory, so it holds in private windows and after a visitor withdraws consent on the privacy page. Without a measurement id, nothing loads at all. I kept the gate in the site's own code rather than in a tag manager's settings, because code can be tested and a settings screen cannot.
The id goes on production and preview, so collection can be checked on a pull request's preview:
vercel env add NEXT_PUBLIC_GA_ID -e production,preview --value G-… --no-sensitive --yes --project yotam-site
There are no manual page views. GA4 can count page changes from browser history events, which covers Next.js navigations. Sending a page view by hand as well would count every page twice, a mistake practitioners report often enough that it has its own write-ups.
Page views alone tell me what was opened, not what was read. So the site sends a small set of events, each with a definition strict enough to trust.
Reading depth is article_progress, sent once each at a quarter, half and three quarters of the article body, not of the whole page, so a long footer does not count as reading. article_complete fires once, only after the three-quarters mark, with the end of the body in view, the tab visible and at least 15 s on the page. That is the event I treat as "read".
The outcome events are newsletter_request, generate_lead for the contact form, and comment_submit. Each fires only on success, never on a failed submit, and each is meant to be a key event in GA4 once it has arrived. The small actions, share and file_download from the export panel and rss_click on the feed link, are counted but are not key events.
Every custom event also carries the page's context: its mode (developer, philosopher or together), its type, its series and its language. The page declares them in data-ga-* attributes, and each one is registered in GA4 as an event-scoped custom dimension, so a report can say which side of the site gets read to the end.
Two kinds of traffic are kept out. Drafts send none of the custom events. And opening any admin page marks that browser as internal, so my own visits can be filtered from the reports. Adding ?ga_internal=0 to any address clears the mark.
The lesson I would give myself on day one is about naming. newsletter_request counts accepted requests, not confirmed signups. The signup is completed by the confirmation email, which is not tracked, and the event name says "request" on purpose.
By default GA4 files visits from AI assistants under Referral, mixed in with ordinary links. So the property has a custom channel group, a copy of the defaults with an AI assistants channel placed directly above Referral. Channel rules run top to bottom and the first match wins, so a visit from an assistant is claimed before Referral can take it. The channel is not retroactive; it fills from the day it was created.
Search Console is linked to the same web stream, so the queries that bring people in sit next to what they read. The admin's own screen shows the referrers from the site's logging too, with no IP addresses, next to the fetches that AI agents make.
Event data is kept for 14 months, the longest GA4 offers, so a year-on-year comparison is possible. Before this advanced layer I ran a research pass across Google's documentation, practitioners and my Trove books, and the limits it found shaped the setup: how long an event name may be, how many parameters an event may carry, and how many custom dimensions a property gets.
GA4's own screens answer Google's questions. I wanted mine answered in the site's admin: what is read, on which side, and what people search for.
Reading uses the Google Analytics Data API with a service account that can only read. It gets no project roles, and only the Viewer role on the analytics property. Its key goes to Vercel as one sensitive variable, on production only, because a key on preview deployments only adds exposure:
base64 -i ~/Downloads/<key>.json | tr -d '\n' | vercel env add GA4_SA_KEY_B64 -e production --sensitive --yes --project yotam-site
Before deleting the downloaded key, one smoke test makes a single report call and prints one line, never the key or the data:
GA4_PROPERTY_ID=<id> GOOGLE_APPLICATION_CREDENTIALS=~/Downloads/<key>.json node web/scripts/ga4-smoke.mjs
status=200 means it works. A 403 means the account is not a Viewer or the API is not enabled. A 400 or 404 usually means the GA id was used where the numeric property id belongs.
The site also keeps some numbers of its own. View counts are stored in the site's own database: one view per visitor, per item, per day, with a visitor key salted daily and pruned after a day, and bots ignored. Site search queries are counted per normalised query, with no IP and no user data. Vercel Web Analytics and Speed Insights run alongside, only on Vercel, for cookieless page counts and real-user performance.
The numbers feed the writing. The admin serves a weekly summary of top items and search queries. When an agent proposes new topics for this site, that summary is one of the places it has to look, next to my journals and repos. The loop closes: what people read shapes what I write next.
Publishing as commits worked: every edit has a history, a diff and a rollback, and there is nothing extra to host. Consent first, with the gate in code, worked. Success-only events with strict definitions mean a key event says what it measures. Reading through a read-only account, with a smoke test that prints one line, made the key safe to set up and easy to rotate.
Some things needed fixing. A check that fails closed blocked live saves until its file was bundled, which was the right failure but a failure all the same. A build-time id silently does nothing until the next deploy. And some GA4 settings cannot be undone: data filters start in testing, and activating one is permanent, so the plan is to let a few days of data arrive before switching them on.
I still use all of it. The live admin is how I edit this site, including the post you are reading.
- Make the content files the source. If your admin writes commits, your host's git integration is your publishing pipeline.
- Put the functions next to the database.
- Gate analytics on consent in code, with denied defaults and no script before acceptance.
- Define each event by its outcome: fire on success only, and name it for what it really measures.
- Read your numbers with a read-only account, and bring them into the place where you decide what to do next.
The companion post, How I designed this site, goes from wireframes to the design contract. Caps, gates and routing belong in code explains why the consent gate and the safety scan are code.
Two books on Trove shaped this setup. SEO for AI Agents (p. 21) says to count AI-assistant referrals by session start, keep sign-in callbacks out, and judge engagement only when it is tied to reading or goals. Web Application Security for AI Agents keeps secrets out of the repo and credentials at least privilege, the rule behind the read-only service account. My own Trove book, the Playbook for GA4 and Search Console on a Next.js site on Vercel, records every step of the setup with its commands and the traps hit.
From Google's documentation, consent came from the consent mode overview and setting up consent mode. The events follow the event collection limits, event naming rules, recommended events, event parameters and key events. Reporting follows custom dimensions, cardinality, enhanced measurement and data retention.
The filters follow internal traffic and data filters, the channels follow custom channel groups and the default channel group, and search follows connecting Search Console.
Practitioners filled the gaps. GA4 double counting in the App Router says to pick history events or a manual page view, never both. Internal traffic filtering sets the internal flag from code and starts filters in testing. A blog's event vocabulary names article_progress and article_complete. GA4 best practices and common mistakes cover retention and duplicate events, tracking AI-assistant traffic gives the channel rule and its place above Referral, and a Next.js discussion shows that the stock GA component has no consent gate.
On Vercel's side, the references were environment variables, Web Analytics and Speed Insights. My own records were the measurement plan in docs/setup/ga4.md, docs/setup/vercel.md, docs/setup/publishing.md, and a /learn research run on GA4 for this site across official docs, practitioners and Trove.
