documentation

blockrate docs

A 1.6 KB client library that measures per-provider block rate. Drop it in, post to your own /api/block-rate route, and let blockrate.app handle storage and visualization.

The reporter endpoint must be first-party.

Your client always posts to a route on your own origin (/api/block-rate), never directly to blockrate.app. A one-line server handler forwards the payload with your API key attached server-side. This keeps the key off the browser and prevents blocklists from silently wiping out your "blocked" counts. Why this matters →

Quick start

terminal
1bun add blockrate

Every integration is two pieces: a client that runs detection and posts to /api/block-rate on your own origin, and a server route that forwards to the ingest endpoint with your API key.

Option A: Hosted dashboard (blockrate.app)

Sign up, create an API key at /app/keys, and set it as BLOCKRATE_API_KEY in your server's environment. The key never touches the browser.

app/api/block-rate/route.ts
1import { createBlockRateHandler } from "blockrate/next";
2
3export const POST = createBlockRateHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});

Client-side, point any BlockRate reporter at that same-origin path:

client.ts
1import { BlockRate } from "blockrate";
2
3new BlockRate({
4 providers: ["optimizely", "posthog", "ga4"],
5 service: "my-app",
6 reporter: (result) => {
7 navigator.sendBeacon("/api/block-rate", JSON.stringify(result));
8 },
9 sampleRate: 0.1, // check 10% of sessions
10}).check();

Option B: Self-hosted (blockrate-server)

Run blockrate-server on your own infrastructure and point the forward helper at it. Same client code, same first-party route, different endpoint.

app/api/block-rate/route.ts
1import { createBlockRateHandler } from "blockrate/next";
2
3export const POST = createBlockRateHandler({
4 forward: {
5 apiKey: process.env.BLOCK_RATE_API_KEY!,
6 endpoint: "https://br.your-domain.com", // your self-hosted blockrate-server
7 },
8});

Option C: Custom pipeline

Skip forward and use onResult to write results anywhere you want — BigQuery, Datadog, a webhook, your own API. The handler still parses and validates the payload, so you only see well-formed BlockRateResult objects.

app/api/block-rate/route.ts
1import { createBlockRateHandler } from "blockrate/next";
2
3export const POST = createBlockRateHandler({
4 onResult: async (result) => {
5 await myLogger.info({ event: "block_rate_check", ...result });
6 },
7});

You can combine forward and onResult — both fire in parallel on a valid payload, failures are isolated, and the browser always gets a 204.

Options

Every field of BlockRateOptions. Only providers and reporter are required.

OptionTypeDefaultDescription
providers(string | Provider)[]List of providers to check. Built-in names ("posthog", "ga4", etc.) or custom Provider objects from createProvider().
reporter(result) => voidCalled once with the full BlockRateResult after detection finishes. For the hosted or self-hosted pattern, post to your same-origin route with navigator.sendBeacon("/api/block-rate", …) or fetch.
servicestringundefinedOptional label forwarded with each event. Useful for slicing the dashboard by app, environment, or surface (e.g. "marketing-site", "checkout").
sampleRatenumber1Fraction of sessions to run the check on, between 0 and 1. Lower it to reduce quota usage on high-traffic sites.
delaynumber (ms)3000Milliseconds to wait before firing any detection. Keeps the check off the critical rendering path. Set to 0 to run immediately.
consentGivenboolean | () => booleantrueOptional consent gate for strict jurisdictions. When false, check() is a complete no-op — no network requests, no data collection. Only needed if your legal counsel requires explicit consent for blockrate; the library is otherwise consent-free by design.
sanitizeUrl(url) => stringundefinedOptional callback to sanitise location.pathname before it hits the reporter. Use this to strip PII from path segments (e.g. /users/:email).
sessionDedupbooleanfalseWhen true, stores a flag in sessionStorage to prevent duplicate checks within the same browser session. Opt-in because writing to sessionStorage may require consent under ePrivacy Article 5(3) in some jurisdictions.
sessionKeystring"__block_rate"Key used for sessionStorage when sessionDedup is enabled. Change it if you run multiple BlockRate instances on the same page.

blockrate is designed to work without a cookie banner — no cookies, no persistent storage (by default), no IP addresses, no cross-site tracking. If your legal counsel still requires explicit consent in your jurisdiction, pass a predicate that reads from your CMP:

app.tsx
1new BlockRate({
2 providers: ["posthog", "ga4"],
3 consentGiven: () => window.CookieConsent?.accepted("analytics"),
4 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
5}).check();

When the predicate returns false, check() is a complete no-op — nothing is loaded, nothing is measured, nothing is sent.

Stripping PII from URL paths

blockrate already strips query strings and hashes — only location.pathname is reported. If your paths themselves contain personal data (email addresses, user IDs, order numbers), use sanitizeUrl to generalise them before they leave the browser:

app.tsx
1new BlockRate({
2 providers: ["posthog", "ga4"],
3 sanitizeUrl: (path) =>
4 path
5 .replace(/\/users\/[^/]+/, "/users/:id")
6 .replace(/\/orders\/\d+/, "/orders/:id"),
7 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
8}).check();

Built-in providers

Each provider is checked first via a post-load window flag — a property only the real bundle sets (e.g. posthog.__loaded, analytics.initialized, google_tag_data), never one the loader snippet creates. Stub globals like window.fbq and window.amplitude are deliberately ignored because the inline snippet runs even when the network request to the CDN is blocked, so checking for them would silently misclassify a blocked install as loaded.

When no reliable post-load flag is available (some providers' loaded shape is too similar to their queueing stub), detection falls through to a fetch HEAD probe to the provider's CDN with mode: "cors". If the ad blocker redirects to a local response (which lacks CORS headers), the fetch throws — correctly detected as blocked. One exception: meta-pixel uses an <img> probe instead, since Meta deliberately serves no CORS headers on their pixel endpoint — the pixel is an image regardless, and ad blockers block the hostname, so onerror is the accurate blocked signal.

NameDetection
optimizelynon-array window.optimizely with .get() + cdn.optimizely.com probe
posthogposthog.__loaded === true + us.i.posthog.com / eu.i.posthog.com probe
ga4window.google_tag_data + google-analytics.com/g/collect probe
gtmwindow.google_tag_manager + googletagmanager.com probe
segmentanalytics.initialized === true + cdn.segment.com probe
hotjarscript.hotjar.com probe (snippet stub indistinguishable)
amplitudecdn.amplitude.com probe (snippet stub varies across SDK majors)
mixpanelmixpanel.__loaded === true + cdn.mxpnl.com probe
meta-pixelfacebook.com/tr image probe (fbq stub sets loaded=true itself)
intercomwidget.intercom.io probe (snippet Intercom is callable stub)

Need a provider we don't ship? Add your own:

custom-provider.ts
1import { BlockRate, createProvider } from "blockrate";
2
3const myProvider = createProvider({
4 name: "my-analytics",
5 detect: async () => (window.myAnalytics ? "loaded" : "blocked"),
6});
7
8new BlockRate({
9 providers: ["posthog", myProvider], // mix built-in + custom
10 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
11}).check();

Framework guides

Every integration is two files: a client that posts to /api/block-rate, and a same-origin server route that forwards upstream. The server route holds the API key; the browser never sees it.

Working examples: every snippet on this page has a matching runnable project in the examples/ directory — clone, bun install, and run. Available for Next.js, TanStack Start, SvelteKit, Nuxt, SolidStart, and plain HTML.

React (generic)

The useBlockRate hook runs once on mount, skips on the server, and handles cleanup. Use it from any React setup; see the Next.js, TanStack Start, or SolidStart sections below for framework-specific server routes.

App.tsx
1import { useBlockRate } from "blockrate/react";
2
3export function App() {
4 useBlockRate({
5 providers: ["optimizely", "posthog", "ga4"],
6 reporter: (r) =>
7 fetch("/api/block-rate", {
8 method: "POST",
9 body: JSON.stringify(r),
10 headers: { "Content-Type": "application/json" },
11 keepalive: true,
12 }),
13 sampleRate: 0.1,
14 });
15
16 return <div>...</div>;
17}

Next.js (App Router)

Drop the <BlockRateScript> component from blockrate/next into your root layout. It's a pre-built client component that wires up the check once on mount and posts the result to your same-origin route — no wrapper file, no "use client" directive needed at the import site.

app/layout.tsx
1import { BlockRateScript } from "blockrate/next";
2
3export default function RootLayout({ children }: { children: React.ReactNode }) {
4 return (
5 <html lang="en">
6 <body>
7 {children}
8 <BlockRateScript
9 providers={["optimizely", "posthog", "ga4"]}
10 endpoint="/api/block-rate"
11 sampleRate={0.1}
12 />
13 </body>
14 </html>
15 );
16}

Pair it with createBlockRateHandler forward does the server-side hop to the ingest endpoint, with your API key read from the server's environment.

app/api/block-rate/route.ts
1import { createBlockRateHandler } from "blockrate/next";
2
3export const POST = createBlockRateHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});

SvelteKit

Call BlockRate in onMount in your root layout, and add a +server.ts route that forwards.

+layout.svelte
1<script lang="ts">
2 import { onMount } from "svelte";
3 import { BlockRate } from "blockrate";
4
5 onMount(() => {
6 new BlockRate({
7 providers: ["optimizely", "posthog", "ga4"],
8 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
9 sampleRate: 0.1,
10 }).check();
11 });
12</script>
13
14<slot />
src/routes/api/block-rate/+server.ts
1import { createBlockRateHandler } from "blockrate/sveltekit";
2
3export const POST = createBlockRateHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});

TanStack Start

Use useBlockRate in the root route component, and add an API file route that forwards.

src/routes/__root.tsx
1import { Outlet, createRootRoute } from "@tanstack/react-router";
2import { useBlockRate } from "blockrate/react";
3
4function RootComponent() {
5 useBlockRate({
6 providers: ["optimizely", "posthog", "ga4"],
7 reporter: (r) =>
8 fetch("/api/block-rate", {
9 method: "POST",
10 body: JSON.stringify(r),
11 headers: { "Content-Type": "application/json" },
12 keepalive: true,
13 }),
14 sampleRate: 0.1,
15 });
16
17 return <Outlet />;
18}
19
20export const Route = createRootRoute({ component: RootComponent });
src/routes/api/block-rate.ts
1import { createFileRoute } from "@tanstack/react-router";
2import { createBlockRateHandler } from "blockrate/tanstack-start";
3
4const handler = createBlockRateHandler({
5 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
6});
7
8export const Route = createFileRoute("/api/block-rate")({
9 server: { handlers: { POST: ({ request }) => handler(request) } },
10});

Nuxt

Run BlockRate inside onMounted in your root Vue component, and add a Nitro server route (server/api/block-rate.post.ts) that forwards.

app.vue
1<script setup lang="ts">
2import { onMounted } from "vue";
3import { BlockRate } from "blockrate";
4
5onMounted(() => {
6 new BlockRate({
7 providers: ["optimizely", "posthog", "ga4"],
8 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
9 sampleRate: 0.1,
10 }).check();
11});
12</script>
13
14<template>
15 <NuxtPage />
16</template>
server/api/block-rate.post.ts
1import { createWebHandler } from "blockrate";
2
3const handle = createWebHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});
6
7export default defineEventHandler((event) => handle(toWebRequest(event)));

SolidStart

Use onMount in your root component, and add an API route under src/routes/api/ that forwards.

src/app.tsx
1import { onMount } from "solid-js";
2import { BlockRate } from "blockrate";
3
4export default function App() {
5 onMount(() => {
6 new BlockRate({
7 providers: ["optimizely", "posthog", "ga4"],
8 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
9 sampleRate: 0.1,
10 }).check();
11 });
12
13 return <div>...</div>;
14}
src/routes/api/block-rate.ts
1import { createWebHandler } from "blockrate";
2
3const handle = createWebHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});
6
7export const POST = (event: { request: Request }) => handle(event.request);

Vanilla JS / script tag

Import the library directly in a script tag and post to your same-origin route. Any HTTP server can host the matching forward route — below is a minimal Bun server. The same shape works for any Vite SPA paired with its own backend (Hono, Express, Fastify, Bun, Workers): createWebHandler returns a Web-standard (Request) => Promise<Response>, so you just need to route POST /api/block-rate to it.

index.html
1<script type="module">
2 import { BlockRate } from "https://esm.sh/blockrate";
3
4 new BlockRate({
5 providers: ["optimizely", "posthog", "ga4"],
6 reporter: (r) => navigator.sendBeacon("/api/block-rate", JSON.stringify(r)),
7 sampleRate: 0.1,
8 }).check();
9</script>
server.ts
1import { createWebHandler } from "blockrate";
2
3const handle = createWebHandler({
4 forward: { apiKey: process.env.BLOCKRATE_API_KEY! },
5});
6
7Bun.serve({
8 port: 3000,
9 fetch: (req) => {
10 const url = new URL(req.url);
11 if (url.pathname === "/api/block-rate" && req.method === "POST") return handle(req);
12 return new Response("not found", { status: 404 });
13 },
14});

Need the hosted API reference? See /docs/api · Self-hosting? See packages/server