Skip to content

Commit 913e3b1

Browse files
authored
feat: add server-side tracking proxy to bypass ad-blockers (#157)
1 parent f0c12b2 commit 913e3b1

9 files changed

Lines changed: 1347 additions & 19 deletions

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ A lightweight, TypeScript-ready Matomo analytics integration for Next.js applica
2424
-**GDPR Compliant** - Cookie-less tracking option
2525
-**Custom Events** - Type-safe event tracking API
2626
-**A/B Testing** - Built-in support for Matomo's A/B Testing plugin with React hooks
27+
-**Server-Side Proxy** - Bypass ad-blockers by routing tracking through your own domain
2728
-**Heatmap & Session Recording** - Optional user behavior visualization
2829
-**TypeScript Support** - Full type safety and auto-completion
2930

@@ -113,6 +114,7 @@ export default function RootLayout({ children }) {
113114
- **[Advanced Configuration](docs/advanced.md)** - All configuration options, HeartBeat timer, callbacks, and extensibility
114115
- **[Event Tracking](docs/events.md)** - Track custom user interactions
115116
- **[A/B Testing](docs/ab-testing.md)** - Integrate Matomo's A/B Testing plugin with React hooks
117+
- **[Server-Side Proxy](docs/server-side-proxy.md)** - Bypass ad-blockers by proxying tracking requests
116118
- **[Heatmap & Session Recording](docs/heatmap-session-recording.md)** - User behavior tracking and visualization
117119
- **[Security & Privacy](docs/security.md)** - CSP configuration and GDPR compliance
118120

@@ -174,16 +176,47 @@ export function HeroSection() {
174176

175177
See the [A/B Testing documentation](docs/ab-testing.md) for scheduling, custom triggers, and advanced usage.
176178

179+
### Server-Side Proxy
180+
181+
Bypass ad-blockers by routing Matomo requests through your own domain using [`withMatomoProxy()`](./src/server-proxy.ts:91):
182+
183+
By default, the public proxy endpoint is generated under your own API namespace: `/api/{random}`.
184+
185+
```js
186+
// next.config.mjs
187+
import { withMatomoProxy } from "@socialgouv/matomo-next";
188+
189+
export default withMatomoProxy({
190+
matomoUrl: "https://analytics.example.com",
191+
})(nextConfig);
192+
```
193+
194+
`matomoUrl` is required here because it’s the **server-side** target the proxy forwards to
195+
(stored in `MATOMO_PROXY_TARGET`, not exposed to the browser).
196+
197+
```tsx
198+
// In your tracker component
199+
import { trackAppRouter } from "@socialgouv/matomo-next";
200+
201+
// When the proxy is configured, calls are automatically routed through your own domain.
202+
// This also hides the usual `matomo.js` / `matomo.php` filenames behind opaque build-time
203+
// random names (some blockers match on those filenames).
204+
trackAppRouter({ siteId: "1", pathname, searchParams });
205+
```
206+
207+
See the [Server-Side Proxy documentation](docs/server-side-proxy.md) for custom paths, chaining with other plugins, and security considerations.
208+
177209
## Configuration Options
178210

179211
| Option | Type | Description | Default | Docs |
180212
| ------------------------------- | ----------------- | ----------------------------------- | --------------------------- | --------------------------------------------------------- |
181-
| `url` | `string` | Matomo instance URL | - | Required |
213+
| `url` | `string` | Matomo instance URL | - | Required unless using the server-side proxy |
182214
| `siteId` | `string` | Matomo site ID | - | Required |
215+
| `useProxy` | `boolean` | Prefer proxy (path + opaque filenames) when available | `true` | Server-side proxy |
183216
| `pathname` | `string` | Current pathname (App Router only) | - | Required for App Router |
184217
| `searchParams` | `URLSearchParams` | URL search params (App Router only) | - | Required for App Router |
185-
| `jsTrackerFile` | `string` | Custom JS tracker filename | `"matomo.js"` | [Advanced](docs/advanced.md) |
186-
| `phpTrackerFile` | `string` | Custom PHP tracker filename | `"matomo.php"` | [Advanced](docs/advanced.md) |
218+
| `jsTrackerFile` | `string` | Custom JS tracker filename | `"matomo.js"` (or proxy-provided opaque name) | [Advanced](docs/advanced.md) |
219+
| `phpTrackerFile` | `string` | Custom PHP tracker filename | `"matomo.php"` (or proxy-provided opaque name) | [Advanced](docs/advanced.md) |
187220
| `excludeUrlsPatterns` | `RegExp[]` | URLs to exclude from tracking | `[]` | [Advanced](docs/advanced.md#exclude-url-patterns) |
188221
| `disableCookies` | `boolean` | Cookie-less tracking | `false` | [Advanced](docs/advanced.md#disable-cookies) |
189222
| `cleanUrl` | `boolean` | Remove query params from URLs | `false` | [Advanced](docs/advanced.md#clean-urls) |

docs/server-side-proxy.md

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
# Server-Side Proxy
2+
3+
Bypass ad-blockers by routing Matomo tracking requests through your own Next.js server with a randomly-generated endpoint that changes on every build.
4+
5+
## Why?
6+
7+
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:
8+
9+
1. **Your domain** — the browser only talks to `yoursite.com`, never to the Matomo domain
10+
2. **Random endpoint** — the proxy path changes on every build (e.g. `/api/a3f7b2c1e9`), so ad-blockers can't hardcode it
11+
3. **True server-side proxy** — requests are forwarded by your API route, not just rewritten
12+
4. **Opaque filenames** — even `matomo.js` / `matomo.php` are hidden behind build-time random names
13+
14+
## How It Works
15+
16+
```
17+
Browser → yoursite.com/api/a3f7b2c1e9/t3fa1c0d2e4 → [Next.js rewrite] → /api/__mp/t3fa1c0d2e4 → [API handler] → analytics.example.com/matomo.php
18+
```
19+
20+
Notes:
21+
- There is **no PHP running on your site**. `matomo.php` is only the upstream Matomo endpoint.
22+
On your domain we use an opaque path (e.g. `t3fa1c0d2e4`) and forward it server-side.
23+
- Route conflicts are practically avoided because the public proxy prefix is **random** and
24+
generated per build (10 hex chars). If you want additional guarantees, provide a custom
25+
`proxyPath` that you know won’t overlap with your existing API routes.
26+
27+
1. `withMatomoProxy()` generates a **random** proxy path at build time (e.g. `/api/a3f7b2c1e9`)
28+
2. It adds a Next.js rewrite: `/api/{random}/:path*``/api/__mp/:path*`
29+
3. You create a catch-all API route with `createMatomoProxyHandler()` that forwards requests to Matomo
30+
4. The browser only ever talks to **your** domain — ad-blockers see nothing suspicious
31+
5. On next deploy, a **new random path** is generated — impossible to maintain a blocklist
32+
33+
## Quick Start
34+
35+
### 1. Wrap your Next.js config
36+
37+
`matomoUrl` is required here because the proxy runs on **your server** and it must know
38+
where to forward requests (your Matomo instance base URL). This value is stored in
39+
`MATOMO_PROXY_TARGET` (server-only) and is **not** exposed to the browser.
40+
41+
```js
42+
// next.config.mjs
43+
import { withMatomoProxy } from "@socialgouv/matomo-next";
44+
45+
const nextConfig = {
46+
// your existing config
47+
};
48+
49+
export default withMatomoProxy({
50+
matomoUrl: "https://analytics.example.com",
51+
siteId: "1", // optional: injects NEXT_PUBLIC_MATOMO_PROXY_SITE_ID
52+
})(nextConfig);
53+
```
54+
55+
### 2. Create the API route handler
56+
57+
Create a catch-all route that forwards requests to Matomo:
58+
59+
```ts
60+
// app/api/__mp/[...path]/route.ts
61+
import { createMatomoProxyHandler } from "@socialgouv/matomo-next";
62+
63+
export const { GET, POST } = createMatomoProxyHandler();
64+
```
65+
66+
That's it! The handler reads the `MATOMO_PROXY_TARGET` env var (set automatically by `withMatomoProxy`) and forwards requests to your Matomo instance.
67+
68+
### 3. Use the proxy in your tracker
69+
70+
When the proxy is configured via `withMatomoProxy()`, the library will **automatically**
71+
route calls through your own domain.
72+
73+
This includes **both** the hostname *and* the usual Matomo filenames:
74+
- the browser will request an opaque `*.js` filename (proxied to upstream `matomo.js`)
75+
- the tracking hits will go to an opaque non-`.php` endpoint (proxied to upstream `matomo.php`)
76+
77+
That means you can omit the Matomo URL entirely (so it doesn't end up in the client bundle),
78+
as long as `NEXT_PUBLIC_MATOMO_PROXY_PATH` is present.
79+
80+
Under the hood, the client uses the proxy **path** (relative URL), so there is
81+
no need to pass your own domain anywhere: the browser automatically resolves it
82+
against the current origin.
83+
84+
```tsx
85+
"use client";
86+
87+
import { usePathname, useSearchParams } from "next/navigation";
88+
import { useEffect } from "react";
89+
import { trackAppRouter } from "@socialgouv/matomo-next";
90+
91+
export function MatomoProvider() {
92+
const pathname = usePathname();
93+
const searchParams = useSearchParams();
94+
95+
useEffect(() => {
96+
trackAppRouter({
97+
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
98+
pathname,
99+
searchParams,
100+
});
101+
}, [pathname, searchParams]);
102+
103+
return null;
104+
}
105+
```
106+
107+
### How the detection works (what happens at runtime)
108+
109+
1. You enable the proxy at build time via [`withMatomoProxy()`](src/server-proxy.ts:154).
110+
This injects client env vars like `NEXT_PUBLIC_MATOMO_PROXY_PATH`.
111+
2. On the client, when you call [`trackAppRouter()`](src/track-app-router.ts:25) (or [`trackPagesRouter()`](src/track-pages-router.ts:19)),
112+
the library detects those env vars and (by default) switches to the proxy automatically (`useProxy: true`).
113+
3. The browser then loads the Matomo JS tracker from your own API endpoint:
114+
`https://yoursite.com/api/{random}/{opaque}.js`.
115+
4. Events triggered via Matomo (including what you queue through `push()` / `sendEvent()`) are sent by the tracker to
116+
`https://yoursite.com/api/{random}/{opaque}`.
117+
5. Next.js rewrites those requests to `/api/__mp/...` and [`createMatomoProxyHandler()`](src/server-proxy.ts:237) forwards them to your Matomo instance (`matomo.js` / `matomo.php`).
118+
119+
If you still want an explicit fallback to the direct Matomo URL, you can keep
120+
passing `url` yourself:
121+
122+
```tsx
123+
import { trackAppRouter } from "@socialgouv/matomo-next";
124+
125+
trackAppRouter({
126+
url: process.env.NEXT_PUBLIC_MATOMO_URL!,
127+
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
128+
pathname,
129+
searchParams,
130+
});
131+
```
132+
133+
Or disable the proxy selection explicitly:
134+
135+
```tsx
136+
trackAppRouter({
137+
url: process.env.NEXT_PUBLIC_MATOMO_URL!,
138+
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
139+
useProxy: false,
140+
pathname,
141+
searchParams,
142+
});
143+
```
144+
145+
### Alternative: Use `getProxyPath()` / `getProxyUrl()`
146+
147+
If you prefer to wire the proxy base URL yourself:
148+
149+
```tsx
150+
import { getProxyPath } from "@socialgouv/matomo-next";
151+
152+
const url = getProxyPath() ?? process.env.NEXT_PUBLIC_MATOMO_URL!;
153+
154+
trackAppRouter({ url, siteId, pathname, searchParams });
155+
```
156+
157+
## API Reference
158+
159+
### `withMatomoProxy(options)`
160+
161+
Wraps your Next.js config to add proxy rewrite rules and environment variables.
162+
163+
| Option | Type | Required | Description |
164+
| ------------ | -------- | -------- | ------------------------------------------------------------------------ |
165+
| `matomoUrl` | `string` || Full URL of your Matomo instance |
166+
| `proxyPath` | `string` || Custom proxy path (default: random per build). ⚠️ Fixed paths reduce ad-block resistance |
167+
| `siteId` | `string` || Injected as `NEXT_PUBLIC_MATOMO_PROXY_SITE_ID` env var |
168+
169+
**Environment variables set:**
170+
171+
| Variable | Scope | Description |
172+
| --------------------------------- | ------ | ------------------------------------ |
173+
| `NEXT_PUBLIC_MATOMO_PROXY_PATH` | Client | The random proxy path (e.g. `/api/a3f7b2c1e9`) |
174+
| `NEXT_PUBLIC_MATOMO_PROXY_JS_TRACKER_FILE` | Client | Opaque JS filename served by your domain (e.g. `s3fa1c0d2e4.js`) |
175+
| `NEXT_PUBLIC_MATOMO_PROXY_PHP_TRACKER_FILE`| Client | Opaque tracking endpoint served by your domain (e.g. `t3fa1c0d2e4`) |
176+
| `MATOMO_PROXY_TARGET` | Server | The Matomo URL (used by the API route handler) |
177+
| `NEXT_PUBLIC_MATOMO_PROXY_SITE_ID`| Client | Site ID (only if `siteId` provided) |
178+
179+
**Returns:** A function that takes a Next.js config and returns the enhanced config.
180+
181+
### `createMatomoProxyHandler()`
182+
183+
Creates Next.js App Router route handlers (GET & POST) that proxy requests to Matomo. Reads `MATOMO_PROXY_TARGET` from the environment.
184+
185+
The handler forwards:
186+
- Query parameters
187+
- User-Agent, Accept-Language, Content-Type headers
188+
- Client IP (`X-Forwarded-For`) for geolocation accuracy
189+
190+
```ts
191+
// app/api/__mp/[...path]/route.ts
192+
import { createMatomoProxyHandler } from "@socialgouv/matomo-next";
193+
export const { GET, POST } = createMatomoProxyHandler();
194+
```
195+
196+
### `getProxyUrl()`
197+
198+
Returns the full proxy URL (`origin + path`) or `null` if not configured.
199+
200+
```ts
201+
getProxyUrl(); // "https://yoursite.com/api/a3f7b2c1e9" or null
202+
```
203+
204+
### `getProxyPath()`
205+
206+
Returns just the proxy path or `null`.
207+
208+
```ts
209+
getProxyPath(); // "/api/a3f7b2c1e9" or null
210+
```
211+
212+
### `generateProxyPath()`
213+
214+
Generates a random opaque path. Used internally by `withMatomoProxy`, but exported for advanced use cases.
215+
216+
```ts
217+
generateProxyPath(); // "/a3f7b2c1e9" (different every call)
218+
```
219+
220+
## What Gets Proxied
221+
222+
| Request | Browser sees | Forwarded to |
223+
| ------------------------------ | ------------------------------------------ | ---------------------------------------------- |
224+
| JS tracker | `yoursite.com/api/{random}/{opaque}.js` | `analytics.example.com/matomo.js` |
225+
| PHP tracker (data collection) | `yoursite.com/api/{random}/{opaque}` | `analytics.example.com/matomo.php` |
226+
| Plugin assets | `yoursite.com/api/{random}/plugins/*` | `analytics.example.com/plugins/*` |
227+
228+
## Advanced Usage
229+
230+
### Custom proxy path
231+
232+
If you want a specific path instead of the auto-generated one (⚠️ reduces ad-block resistance):
233+
234+
```js
235+
export default withMatomoProxy({
236+
matomoUrl: "https://analytics.example.com",
237+
proxyPath: "/t",
238+
})(nextConfig);
239+
```
240+
241+
### Preserving existing rewrites
242+
243+
`withMatomoProxy` preserves any existing rewrite rules in your config:
244+
245+
```js
246+
const nextConfig = {
247+
rewrites: async () => [
248+
{ source: "/old-page", destination: "/new-page" },
249+
],
250+
};
251+
252+
// Both the existing rewrite and Matomo rewrites will be active
253+
export default withMatomoProxy({
254+
matomoUrl: "https://analytics.example.com",
255+
})(nextConfig);
256+
```
257+
258+
### Chaining with other Next.js plugins
259+
260+
```js
261+
import { withMatomoProxy } from "@socialgouv/matomo-next";
262+
import withBundleAnalyzer from "@next/bundle-analyzer";
263+
264+
const nextConfig = { /* ... */ };
265+
266+
export default withMatomoProxy({
267+
matomoUrl: "https://analytics.example.com",
268+
})(
269+
withBundleAnalyzer({ enabled: false })(nextConfig)
270+
);
271+
```
272+
273+
### Pages Router API route
274+
275+
If you're using the Pages Router instead of App Router, create the handler at `pages/api/__mp/[...path].ts`:
276+
277+
```ts
278+
// pages/api/__mp/[...path].ts
279+
import type { NextApiRequest, NextApiResponse } from "next";
280+
281+
export default async function handler(
282+
req: NextApiRequest,
283+
res: NextApiResponse,
284+
) {
285+
const target = process.env.MATOMO_PROXY_TARGET;
286+
if (!target) return res.status(500).end("Proxy not configured");
287+
288+
const { path } = req.query;
289+
const pathStr = Array.isArray(path) ? path.join("/") : (path ?? "");
290+
const targetUrl = new URL(`/${pathStr}`, target);
291+
292+
// Forward query params (excluding 'path' used by catch-all route)
293+
for (const [key, value] of Object.entries(req.query)) {
294+
if (key !== "path" && typeof value === "string") {
295+
targetUrl.searchParams.set(key, value);
296+
}
297+
}
298+
299+
const headers: Record<string, string> = {};
300+
if (req.headers["user-agent"]) headers["user-agent"] = req.headers["user-agent"];
301+
if (req.headers["accept-language"]) headers["accept-language"] = req.headers["accept-language"] as string;
302+
if (req.headers["content-type"]) headers["content-type"] = req.headers["content-type"];
303+
if (req.headers["x-forwarded-for"]) headers["x-forwarded-for"] = req.headers["x-forwarded-for"] as string;
304+
305+
const response = await fetch(targetUrl.toString(), {
306+
method: req.method ?? "GET",
307+
headers,
308+
body: req.method !== "GET" && req.method !== "HEAD" ? JSON.stringify(req.body) : undefined,
309+
});
310+
311+
res.status(response.status);
312+
const contentType = response.headers.get("content-type");
313+
if (contentType) res.setHeader("content-type", contentType);
314+
315+
const buffer = Buffer.from(await response.arrayBuffer());
316+
res.end(buffer);
317+
}
318+
```
319+
320+
## Security Considerations
321+
322+
- The proxy path is **random** and **changes every build** — ad-blockers cannot maintain a static blocklist
323+
- No sensitive data (API keys, tokens) is embedded in the proxy
324+
- `MATOMO_PROXY_TARGET` is a **server-only** env var — never exposed to the browser
325+
- Matomo's own security (CORS, auth tokens) still applies
326+
- The handler only proxies to the configured Matomo URL — it cannot be abused to proxy arbitrary destinations
327+
- Consider adding rate-limiting in production via middleware or your hosting platform

0 commit comments

Comments
 (0)