Skip to content

6 min read

First-party analytics and ad blockers: reliability with consent

Why ad blockers stop analytics scripts, how first-party serving works with a Next.js rewrite, what it leaves unchanged on consent, and how to measure coverage.

If your analytics shows fewer signups than your database, ad blockers are one of the usual suspects. Serving first party analytics, from your own domain, makes measurement more reliable against the most common kind of blocking. It also leaves several things exactly as they were, and those matter as much as the setup. This post covers why ad blockers stop analytics scripts, what first-party serving means in practice, how to do it with a Next.js rewrite, and how to measure the result instead of assuming you now see everyone.

Why do ad blockers block analytics scripts?

Most content blockers work from filter lists. A list contains rules that match requests by domain and path, and requests to well-known analytics and advertising domains are blocked wherever they appear. The rule doesn't know or care which site loaded the script.

Blocking also happens outside browser extensions. Some browsers ship with tracker blocking on by default. DNS-level blockers on home networks, and filtering on company networks, drop requests to the same domains for every device behind them.

The effect on your data is the same in each case. The script never loads, or it loads and its requests to the collection endpoint fail. The visitor sees your site normally. Your numbers lose that visit without any error.

How much you lose depends on who visits. Audiences differ a lot, so the only number worth using is the one you measure on your own site, which is covered further down.

What does first-party serving mean?

The script is served from your own domain, and the events it sends go to an endpoint on your own domain. From the browser's point of view, every request is same-origin. Your server or edge network then forwards those requests to the analytics provider.

There are two common ways to do it:

  • A path-based proxy. Requests to a path such as /stats/script.js on your site are forwarded to the provider. In Next.js this is a rewrite in your config. Most hosts and reverse proxies can do the same.
  • A CNAME subdomain. A subdomain like stats.yourdomain.com points at the provider's servers through DNS. It looks first party but is served by someone else's infrastructure, cookies scoped to your main domain can be sent to it, and some blockers resolve the CNAME to find the real host. A path-based proxy is usually the cleaner choice.

How do you set it up in Next.js?

With a rewrite. Requests to the source path are fetched from the destination and returned under your URL. The browser never contacts the provider's domain directly.

// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  async rewrites() {
    return [
      {
        source: "/stats/script.js",
        destination: "https://analytics.example.com/script.js",
      },
      {
        source: "/stats/api/:path*",
        destination: "https://analytics.example.com/api/:path*",
      },
    ];
  },
};

export default config;

Then load the script from your own path and point its endpoint at your own domain. The attribute name varies by provider:

<script defer src="/stats/script.js" data-api="/stats/api"></script>

Providers that support this usually document which paths to forward and which option sets the endpoint. Follow their version over a generic one.

A few things to check once it is live:

  • Use a plain, honest path. /stats is fine. The goal is to serve from your own origin, not to disguise what the script is. If a visitor's blocker targets your first-party path as well, that's their decision and it should stand.
  • Location data. The provider now receives requests from your server rather than from the visitor. Depending on your host and the provider, country detection may need the original IP forwarded in a header the provider documents. If you forward it, that's personal data passing through your infrastructure, and your privacy policy should say so.
  • Caching. The script should be cached, not fetched from the provider on every page view. Check the response headers on your proxied path.
  • Your content security policy. With everything same-origin, you may be able to drop the provider's domain from script-src and connect-src.

What doesn't first-party serving change?

This is the part that decides whether the setup is done properly.

Consent rules apply the same way. In the EU, the question that decides whether analytics needs consent is what the script stores on or reads from the visitor's device, and what you do with it. The domain it is served from is not part of that test. A script that writes an identifier needs consent whether it loads from your domain or someone else's. Whether a particular setup can run without consent depends on what it stores and on local guidance, so check it against your own case. The post on analytics consent and GDPR goes through that.

Choices people make still apply. If a visitor refuses consent, the proxied script should record nothing that identifies them. If you have said you honour Global Privacy Control or a similar signal, that promise doesn't change with the hosting. And when consent is refused, fingerprinting or server-side identification is not a substitute.

Your privacy policy still names the provider. It is still a processor of your visitors' data, even though the requests now pass through you.

Some visits stay invisible. Visitors who refuse consent, browse with scripts disabled, or read on their phone and sign up on their laptop are still not linked. First-party serving addresses one cause of missing data, not all of them.

Put simply, first-party serving is about reliability for visitors who have agreed to be measured. It is not a way around the ones who haven't.

How do you know how much you are measuring?

Don't assume first-party serving gets you to 100%. Measure the gap before and after.

The cleanest check compares something your analytics records with the same thing in a system of record. Signups work well, because your database knows every one of them:

select
  count(*) as signups,
  count(*) filter (where first_touch is not null) as with_first_touch,
  round(100.0 * count(*) filter (where first_touch is not null) / count(*), 1) as share_pct
from users
where created_at >= now() - interval '30 days';

If you link visits to revenue, the same idea applies to money: the attributed share is attributed new MRR divided by all new MRR in the period. Run either check for a few weeks before switching to first-party serving and a few weeks after. A rise tells you roughly what blocking was costing you. What remains is consent refusals, device switches and anything else that breaks the chain, and it should be shown next to every report built on this data, not hidden. The post on which blog posts bring paying customers shows how to read rankings with that share in mind.

Joinsight's snippet is under 2 KB, waits for consent before storing anything, and can be served from your own domain by proxying /j.js, /api/ingest and /api/identify and setting the data-api attribute on the script tag. Every report shows the attributed share. What the snippet stores, and when, is on the privacy page.

See what makes you money.

Connect Stripe or RevenueCat and Search Console, and you see combined MRR and 16 months of search history on day one. Page and keyword revenue follow as the snippet collects first touches.

No card required. The trial clock starts when your first revenue source connects.