Vercel Analytics vs Google Analytics 4: The Consent Banner Decides It
A banner-gated tool measures the subset of visitors who agreed to be measured. The cookieless trade, the typed event union, and the CSP origin we nearly added for nothing.
Pick your analytics by the consent banner, not the feature list. Google Analytics 4 sets cookies, so in the EU and UK it needs consent — and a banner that suppresses it until someone clicks Accept measures a self-selected sample. Cookieless tools skip the banner entirely and count everyone, at the cost of cross-session identity.
That trade is the whole decision. Everything else on the comparison table is a preference; this one changes what your numbers mean.
The measurement most teams never make
A consent banner does not reduce your data by the share of people who decline. It reduces it by the share who decline plus the share who ignore the banner and browse anyway, plus everyone who bounced before the banner rendered. Those three groups are not a random sample of your audience — they skew toward the impatient, the privacy-conscious, and the mobile visitor on a slow connection, which is to say the people whose experience you most need to see.
If your analytics requires consent, you are not measuring your site. You are measuring the subset of visitors who agreed to be measured.
The comparison that matters
| Vercel Web Analytics | Google Analytics 4 | Plausible / Fathom / Umami | |
|---|---|---|---|
| Cookies | None | _ga by default | None |
| Consent banner in EU/UK | Not required for the analytics itself | Required in practice | Not required for the analytics itself |
| Sample | Every visitor | Post-consent subset | Every visitor |
| Cross-session identity | No | Yes | No |
| Custom events | Yes, name + flat props | Yes, rich parameter model | Yes, name + props |
| Funnels, cohorts, audiences | No | Yes | Limited or none |
| Ads / remarketing integration | No | Yes, this is the point of it | No |
| Where the script comes from | Injected at the edge on Vercel | googletagmanager.com | Vendor CDN or self-hosted |
| Extra CSP origins needed | One script-src host | Two or more, incl. connect-src | One, or none if self-hosted |
| Cost model | Per event, by plan | Free at storefront volume | Paid, or free self-hosted |
| Data export / portability | Limited | BigQuery export | Varies; self-host means you own it |
Read that table from the bottom row up if you run ads. GA4's remarketing and conversion-import integration is a genuine, hard-to-replace capability, and no cookieless tool substitutes for it. If paid acquisition is your channel, you are going to run GA4 and a consent banner, and the right move is to accept that and instrument carefully rather than pretend a cookieless tool covers it.
If your acquisition is organic — search, content, word of mouth — the cookieless option measures more of your actual audience and costs you a cross-session identity you were probably not using.
What we run, and why
This storefront is organic-acquisition and statically rendered, so it runs Vercel Web Analytics with no consent banner. Two lines:
// src/app/layout.tsx
import { Analytics } from "@vercel/analytics/next";
// …inside <body>
<Analytics />
The script is injected by Vercel at the edge, on real deployments only. That has one consequence worth knowing before you spend an afternoon on it, covered under Mistakes below.
Custom events are where the value is, and the type is the discipline
Pageviews tell you a page is popular. They do not tell you it earned anything. The events are what connect content to revenue, and the single highest-leverage decision is to make the event names a closed union rather than free strings:
// src/lib/analytics.ts
import { track as vercelTrack } from "@vercel/analytics";
type ConversionEvent =
| "preview_opened"
| "buy_clicked"
| "waitlist_joined"
| "blog_cta_clicked"
| "wishlist_added";
export function track(
event: ConversionEvent,
props?: Record<string, string | number>,
) {
try {
vercelTrack(event, props);
} catch {
// Analytics must never break UX — swallow any client-side error.
}
}
Five names, one per funnel step. A typo is a build error, not a new event that quietly splits a metric in half for three months before anyone notices. Every event-tracking system decays the same way — buy_click, buyClicked, buy_clicked_v2 — and the union is a two-line fix for it.
The try/catch is not defensive padding. An analytics call sits inside a click handler on a buy button; a throw there is a broken purchase.
The props are the reporting
The event that pays for the blog is this one:
track("blog_cta_clicked", { post: postSlug, target: productSlug });
Both halves matter. With only post you learn that an article converts but not to what. With only target you learn a product gets blog traffic but not which article sent it. With both, every article has a per-destination click-through rate, which is the number that decides what to write next.
Compare that to session-scoped reporting, where a reader who lands on an article, browses four products and buys one shows up as "the blog contributed to a session". Per-event attribution answers the editorial question; per-session attribution answers nothing you can act on.
One rule: never pass PII
No email, no name, no user id, ever, in an event prop. This is not only a privacy rule — it is what keeps the tool cookieless in substance and not just in mechanism. A cookieless analytics stream that carries a stable user id has re-identified the visitor and re-acquired every obligation you switched tools to avoid.
The third-party script tax nobody budgets for
Anything you mount in the root layout runs on every page, including the ones that do not need it. We learned this the expensive way with our own dependencies rather than with an analytics vendor: the Supabase auth client was imported at module scope by the site header, which put @supabase/ssr plus auth-js — 68 KiB gzipped, 255 KiB parsed — into the initial bundle of the home page, the blog, the docs and the pricing page. Four routes with no account UI on them at all, parsing an auth SDK before they could paint.
A tag manager in the root layout is the same shape of mistake with a bigger constant. Before you add one, decide which pages actually need the measurement, and check what the script costs on a phone rather than on your laptop.
The CSP consequence, and an allowlist trap
Any analytics you add has to be named in your Content Security Policy, and this is where allowlists quietly rot. Ours needs exactly one extra origin:
script-src 'self' 'unsafe-inline' … https://va.vercel-scripts.com
connect-src 'self' <supabase-origin>
The instinct is to add va.vercel-scripts.com to connect-src as well — the script is loaded from there, so surely it reports back there. Reading the package source showed the beacon posts to a first-party path on the site's own origin, already covered by 'self'. The extra origin would have been permanent, invisible, and pure attack surface.
The rule that falls out: every origin in the policy must be traceable to a line of code that loads it, with the reason written next to it. GA4 legitimately needs more origins than a first-party beacon does; that is a real cost of the choice, not a reason to avoid it. Our full policy and why it lives in next.config.ts rather than middleware is in the static-rendering CSP post.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Reading local Lighthouse as a real score | Best Practices caps at 96 — the beacon 404s locally | Re-measure on the deployment; it reads 100 there |
Expecting events in next dev | The script only exists on a real deployment; track() no-ops | Verify on a preview deployment, not localhost |
| Free-string event names | Metrics silently fork across near-duplicate names | Closed union type on the track wrapper |
| Event name only, no props | You see that something converted, not what to | Carry source and destination in every funnel event |
| Tracking removals as well as adds | Counts become uninterpretable | Instrument the funnel step, not the toggle |
| PII in props | Re-identifies the visitor; consent obligations return | Slugs, tiers and counts only |
Analytics call outside try/catch | A vendor error breaks a buy button | Wrap it; analytics must never break UX |
| Adding the vendor's host to every directive | Allowlist grows past auditability | One origin per line of code that loads it |
| Banner-gated analytics treated as complete data | Decisions made on a self-selected sample | Either accept the bias explicitly or go cookieless |
Frequently asked questions
Is cookieless analytics automatically GDPR-compliant? No — "no cookies" removes the ePrivacy consent trigger for storage on the device, but you are still processing data and still owe a privacy notice and a lawful basis. What it removes is the banner, which is a UX and data-quality win, not a compliance exemption. Check the specifics with someone qualified for your jurisdiction; this is an engineering post, not legal advice.
Can I run both? Yes, and for a site with paid acquisition it is common: GA4 behind consent for ads and audiences, a cookieless tool unconditionally for the honest traffic picture. The cost is two sets of numbers that will never agree, so decide in advance which one is the source of truth for each question and write it down.
Why not just self-host something? It is a good option — Umami or Plausible self-hosted means the data never leaves your infrastructure and the script can be first-party, which removes the CSP origin entirely. The cost is that you now operate a database and a dashboard. Worth it if data ownership is a requirement; not worth it to save a subscription.
Does an analytics script hurt Core Web Vitals? A small, deferred, first-party beacon is close to free. A tag manager loading several vendors is not, and it lands on the main thread during hydration, which is exactly when a mobile device is busiest. Measure it under Lighthouse's mobile preset — 4× CPU throttle on slow 4G — because on a desktop connection almost nothing looks expensive.
How do I know my events are actually firing?
Deploy to a preview URL and click the funnel yourself with the network panel open. Local verification is impossible by design here, and a "the event never appeared" bug is almost always someone testing on localhost.
Templates with the measurement surface already built
Instrumentation is easiest when the page already has the conversion points in it. Three of ours are built around live dashboard previews, which is the pattern that makes an event worth firing in the first place.
ASoc Ledger is a financial-management SaaS site built around a live analytics dashboard preview with net-sales trends and connected trackers. ASoc Beacon markets mobile-device-management software and leads with a device-console mock — enrolled devices, compliance state, OS split. ASoc Relay is a messaging-platform site with a live chat-widget hero and outcome stats across 142 countries.
Browse the full set of Next.js landing page templates or the Tailwind landing page templates. If you are wiring the events into forms, the Server Actions contact-form post covers the submission path they hang off.
