|
| 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