# Proxy overview

Serve a.js from your own domain, and send events through your own server without breaking visitor counts or country data.

You can serve the script from your own domain, and on the Scale plan you can send events through your own server too. Read the warning below before you add a plain rewrite for the events endpoint.

Prefer not to run anything? The [managed proxy](/docs/proxy/managed) serves the script and events from a subdomain of yours with one CNAME record.

## Pick your platform

| Platform | Serve the script | Forward events (Scale) |
|---|---|---|
| [Next.js](/docs/proxy/nextjs) | `rewrites` in `next.config.js` | Route handler |
| [Vercel](/docs/proxy/vercel) | `vercel.json` rewrite | Vercel Function |
| [Netlify](/docs/proxy/netlify) | `_redirects` | Netlify Function |
| [Cloudflare Worker](/docs/proxy/cloudflare-worker) | Worker route | Same Worker |
| [Nginx](/docs/proxy/nginx) | `location` block | Through your app server |
| [Caddy](/docs/proxy/caddy) | `reverse_proxy` | Through your app server |
| [PHP](/docs/proxy/php) | A PHP file | A PHP file |
| [Flask](/docs/proxy/flask) | Route | Route |
| [FastAPI](/docs/proxy/fastapi) | Route | Route |
| [Laravel](/docs/proxy/laravel) | Route | Route |
| [Firebase Hosting](/docs/proxy/firebase-hosting) | Cloud Function | Same function |
| [DigitalOcean App Platform](/docs/proxy/digitalocean) | Service component | Same service |

## What a proxy does and does not fix for ad blockers

Blocklists match two things: the script URL and the domain events are sent to.

- **Serving only the script** from your domain gets it past rules that match `api.productlense.app/a.js`. Events still go to `api.productlense.app`, so a blocker that lists that domain still drops them.
- **Forwarding events as well** makes every request first-party. This is the only setup that survives domain-based blocking, and it needs an API key, which is why it is on the Scale plan.

Pick a neutral path such as `/js/a.js` or `/assets/m.js`. Paths containing words like `analytics`, `tracker` or `productlense` are on many lists.

## How the script picks its endpoints

The script posts events to `<base>/api/e`. The base is the `data-api` attribute when set, otherwise the origin the script was loaded from. So if you serve the script from `https://example.com/js/a.js` without `data-api`, events go to `https://example.com/api/e`, which is probably your own application's route.

> [!IMPORTANT]
> Do not forward `/api/e` to ProductLense with a plain rewrite or reverse proxy. ProductLense computes the visitor id from the connecting IP address and takes the country from that address. Through a proxy, every visitor arrives from your proxy's IP: unique visitors collapse towards one per user agent per day, and every country becomes the proxy's location. ProductLense trusts forwarded IP headers only from its own edge network.

## Option 1: serve only the script (any plan)

Serve `a.js` from your domain and keep events going straight to ProductLense with `data-api`.

```js tab="Next.js" title="next.config.js"
module.exports = {
  async rewrites() {
    return [{ source: '/js/a.js', destination: 'https://api.productlense.app/a.js' }];
  },
};
```

```json tab="Vercel" title="vercel.json"
{
  "rewrites": [{ "source": "/js/a.js", "destination": "https://api.productlense.app/a.js" }]
}
```

```text tab="Netlify" title="_redirects"
/js/a.js  https://api.productlense.app/a.js  200
```

Then point the tag at your path and set the events base:

```html
<script defer data-site="SITE_ID" data-api="https://api.productlense.app" src="/js/a.js"></script>
```

ProductLense serves `a.js` with `Cache-Control: public, max-age=86400`; keep that or shorter on your side so updates reach your visitors within a day.

## Option 2: send events through your server (Scale plan)

Your server receives the browser's batches and forwards them to the [Events API](/docs/api/events), adding the visitor's real IP and user agent to every event. The Events API accepts `ip` and `ua` per event, so visitor ids and countries stay correct.

1. Create an API key with the `events` scope (see [Authentication and API keys](/docs/api/authentication)).
2. Add a route at `/m/api/e` on your domain that forwards each request as below.
3. Set `data-api="https://example.com/m"` on the script tag.

```js tab="Node" title="server.js (Express)"
import express from 'express';

const app = express();
app.set('trust proxy', true); // req.ip is the visitor's address behind your load balancer

app.post('/m/api/e', express.text({ type: '*/*', limit: '8kb' }), async (req, res) => {
  let body;
  try {
    body = JSON.parse(req.body);
  } catch {
    return res.sendStatus(400);
  }
  const extra = { ip: req.ip, ua: req.get('user-agent') ?? '' };
  const out = body.b ? { ...body, b: body.b.map((e) => ({ ...e, ...extra })) } : { ...body, ...extra };
  const r = await fetch('https://api.productlense.app/api/v1/events', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PL_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(out),
  });
  res.sendStatus(r.status);
});
```

```go tab="Go" title="proxy.go"
func productLenseProxy(w http.ResponseWriter, r *http.Request) {
	var body map[string]any
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	ip, _, _ := net.SplitHostPort(r.RemoteAddr) // use your load balancer's client IP header if you have one
	add := func(e map[string]any) { e["ip"], e["ua"] = ip, r.UserAgent() }
	if batch, ok := body["b"].([]any); ok {
		for _, e := range batch {
			if m, ok := e.(map[string]any); ok {
				add(m)
			}
		}
	} else {
		add(body)
	}
	out, _ := json.Marshal(body)
	req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://api.productlense.app/api/v1/events", bytes.NewReader(out))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PL_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "upstream error", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	w.WriteHeader(resp.StatusCode)
}
```

The browser sends `text/plain` bodies through `navigator.sendBeacon`, so read the raw body rather than relying on a JSON body parser. The Events API checks that the site id in the body belongs to the key's site and that the page hostname is one of the site's hostnames.

## Related

- [Events API](/docs/api/events)
- [Bots and filtering](/docs/track/bots-and-filtering)
