Bypass ad-blockers by routing Matomo tracking requests through your own Next.js server with a randomly-generated endpoint that changes on every build.
Ad-blockers commonly block requests to known analytics domains (e.g. *.matomo.cloud, analytics.example.com). They also maintain lists of known proxy paths. This proxy solves both problems:
- Your domain — the browser only talks to
yoursite.com, never to the Matomo domain - Random endpoint — the proxy path changes on every build (e.g.
/api/a3f7b2c1e9), so ad-blockers can't hardcode it - True server-side proxy — requests are forwarded by your API route, not just rewritten
- Opaque filenames — even
matomo.js/matomo.phpare hidden behind build-time random names
Browser → yoursite.com/api/a3f7b2c1e9/t3fa1c0d2e4 → [Next.js rewrite] → /api/__mp/t3fa1c0d2e4 → [API handler] → analytics.example.com/matomo.php
Notes:
- There is no PHP running on your site.
matomo.phpis only the upstream Matomo endpoint. On your domain we use an opaque path (e.g.t3fa1c0d2e4) and forward it server-side. - Route conflicts are practically avoided because the public proxy prefix is random and
generated per build (10 hex chars). If you want additional guarantees, provide a custom
proxyPaththat you know won’t overlap with your existing API routes.
withMatomoProxy()generates a random proxy path at build time (e.g./api/a3f7b2c1e9)- It adds a Next.js rewrite:
/api/{random}/:path*→/api/__mp/:path* - You create a catch-all API route with
createMatomoProxyHandler()that forwards requests to Matomo - The browser only ever talks to your domain — ad-blockers see nothing suspicious
- On next deploy, a new random path is generated — impossible to maintain a blocklist
matomoUrl is required here because the proxy runs on your server and it must know
where to forward requests (your Matomo instance base URL). This value is stored in
MATOMO_PROXY_TARGET (server-only) and is not exposed to the browser.
// next.config.mjs
import { withMatomoProxy } from "@socialgouv/matomo-next";
const nextConfig = {
// your existing config
};
export default withMatomoProxy({
matomoUrl: "https://analytics.example.com",
siteId: "1", // optional: injects NEXT_PUBLIC_MATOMO_PROXY_SITE_ID
})(nextConfig);Create a catch-all route that forwards requests to Matomo:
// app/api/__mp/[...path]/route.ts
import { createMatomoProxyHandler } from "@socialgouv/matomo-next";
export const { GET, POST } = createMatomoProxyHandler();That's it! The handler reads the MATOMO_PROXY_TARGET env var (set automatically by withMatomoProxy) and forwards requests to your Matomo instance.
When the proxy is configured via withMatomoProxy(), the library will automatically
route calls through your own domain.
This includes both the hostname and the usual Matomo filenames:
- the browser will request an opaque
*.jsfilename (proxied to upstreammatomo.js) - the tracking hits will go to an opaque non-
.phpendpoint (proxied to upstreammatomo.php)
That means you can omit the Matomo URL entirely (so it doesn't end up in the client bundle),
as long as NEXT_PUBLIC_MATOMO_PROXY_PATH is present.
Under the hood, the client uses the proxy path (relative URL), so there is no need to pass your own domain anywhere: the browser automatically resolves it against the current origin.
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { trackAppRouter } from "@socialgouv/matomo-next";
export function MatomoProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
trackAppRouter({
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
pathname,
searchParams,
});
}, [pathname, searchParams]);
return null;
}- You enable the proxy at build time via
withMatomoProxy(). This injects client env vars likeNEXT_PUBLIC_MATOMO_PROXY_PATH. - On the client, when you call
trackAppRouter()(ortrackPagesRouter()), the library detects those env vars and (by default) switches to the proxy automatically (useProxy: true). - The browser then loads the Matomo JS tracker from your own API endpoint:
https://yoursite.com/api/{random}/{opaque}.js. - Events triggered via Matomo (including what you queue through
push()/sendEvent()) are sent by the tracker tohttps://yoursite.com/api/{random}/{opaque}. - Next.js rewrites those requests to
/api/__mp/...andcreateMatomoProxyHandler()forwards them to your Matomo instance (matomo.js/matomo.php).
If you still want an explicit fallback to the direct Matomo URL, you can keep
passing url yourself:
import { trackAppRouter } from "@socialgouv/matomo-next";
trackAppRouter({
url: process.env.NEXT_PUBLIC_MATOMO_URL!,
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
pathname,
searchParams,
});Or disable the proxy selection explicitly:
trackAppRouter({
url: process.env.NEXT_PUBLIC_MATOMO_URL!,
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
useProxy: false,
pathname,
searchParams,
});If you prefer to wire the proxy base URL yourself:
import { getProxyPath } from "@socialgouv/matomo-next";
const url = getProxyPath() ?? process.env.NEXT_PUBLIC_MATOMO_URL!;
trackAppRouter({ url, siteId, pathname, searchParams });Wraps your Next.js config to add proxy rewrite rules and environment variables.
| Option | Type | Required | Description |
|---|---|---|---|
matomoUrl |
string |
✅ | Full URL of your Matomo instance |
proxyPath |
string |
❌ | Custom proxy path (default: random per build). |
siteId |
string |
❌ | Injected as NEXT_PUBLIC_MATOMO_PROXY_SITE_ID env var |
Environment variables set:
| Variable | Scope | Description |
|---|---|---|
NEXT_PUBLIC_MATOMO_PROXY_PATH |
Client | The random proxy path (e.g. /api/a3f7b2c1e9) |
NEXT_PUBLIC_MATOMO_PROXY_JS_TRACKER_FILE |
Client | Opaque JS filename served by your domain (e.g. s3fa1c0d2e4.js) |
NEXT_PUBLIC_MATOMO_PROXY_PHP_TRACKER_FILE |
Client | Opaque tracking endpoint served by your domain (e.g. t3fa1c0d2e4) |
MATOMO_PROXY_TARGET |
Server | The Matomo URL (used by the API route handler) |
NEXT_PUBLIC_MATOMO_PROXY_SITE_ID |
Client | Site ID (only if siteId provided) |
Returns: A function that takes a Next.js config and returns the enhanced config.
Creates Next.js App Router route handlers (GET & POST) that proxy requests to Matomo. Reads MATOMO_PROXY_TARGET from the environment.
The handler forwards:
- Query parameters
- User-Agent, Accept-Language, Content-Type headers
- Client IP (
X-Forwarded-For) for geolocation accuracy
// app/api/__mp/[...path]/route.ts
import { createMatomoProxyHandler } from "@socialgouv/matomo-next";
export const { GET, POST } = createMatomoProxyHandler();Returns the full proxy URL (origin + path) or null if not configured.
getProxyUrl(); // "https://yoursite.com/api/a3f7b2c1e9" or nullReturns just the proxy path or null.
getProxyPath(); // "/api/a3f7b2c1e9" or nullGenerates a random opaque path. Used internally by withMatomoProxy, but exported for advanced use cases.
generateProxyPath(); // "/a3f7b2c1e9" (different every call)| Request | Browser sees | Forwarded to |
|---|---|---|
| JS tracker | yoursite.com/api/{random}/{opaque}.js |
analytics.example.com/matomo.js |
| PHP tracker (data collection) | yoursite.com/api/{random}/{opaque} |
analytics.example.com/matomo.php |
| Plugin assets | yoursite.com/api/{random}/plugins/* |
analytics.example.com/plugins/* |
If you want a specific path instead of the auto-generated one (
export default withMatomoProxy({
matomoUrl: "https://analytics.example.com",
proxyPath: "/t",
})(nextConfig);withMatomoProxy preserves any existing rewrite rules in your config:
const nextConfig = {
rewrites: async () => [{ source: "/old-page", destination: "/new-page" }],
};
// Both the existing rewrite and Matomo rewrites will be active
export default withMatomoProxy({
matomoUrl: "https://analytics.example.com",
})(nextConfig);import { withMatomoProxy } from "@socialgouv/matomo-next";
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
/* ... */
};
export default withMatomoProxy({
matomoUrl: "https://analytics.example.com",
})(withBundleAnalyzer({ enabled: false })(nextConfig));If you're using the Pages Router instead of App Router, create the handler at pages/api/__mp/[...path].ts:
// pages/api/__mp/[...path].ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const target = process.env.MATOMO_PROXY_TARGET;
if (!target) return res.status(500).end("Proxy not configured");
const { path } = req.query;
const pathStr = Array.isArray(path) ? path.join("/") : (path ?? "");
const targetUrl = new URL(`/${pathStr}`, target);
// Forward query params (excluding 'path' used by catch-all route)
for (const [key, value] of Object.entries(req.query)) {
if (key !== "path" && typeof value === "string") {
targetUrl.searchParams.set(key, value);
}
}
const headers: Record<string, string> = {};
if (req.headers["user-agent"])
headers["user-agent"] = req.headers["user-agent"];
if (req.headers["accept-language"])
headers["accept-language"] = req.headers["accept-language"] as string;
if (req.headers["content-type"])
headers["content-type"] = req.headers["content-type"];
if (req.headers["x-forwarded-for"])
headers["x-forwarded-for"] = req.headers["x-forwarded-for"] as string;
const response = await fetch(targetUrl.toString(), {
method: req.method ?? "GET",
headers,
body:
req.method !== "GET" && req.method !== "HEAD"
? JSON.stringify(req.body)
: undefined,
});
res.status(response.status);
const contentType = response.headers.get("content-type");
if (contentType) res.setHeader("content-type", contentType);
const buffer = Buffer.from(await response.arrayBuffer());
res.end(buffer);
}- The proxy path is random and changes every build — ad-blockers cannot maintain a static blocklist
- No sensitive data (API keys, tokens) is embedded in the proxy
MATOMO_PROXY_TARGETis a server-only env var — never exposed to the browser- Matomo's own security (CORS, auth tokens) still applies
- The handler only proxies to the configured Matomo URL — it cannot be abused to proxy arbitrary destinations
- Consider adding rate-limiting in production via middleware or your hosting platform