[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt)

# Server SDK

> @invisitdev/server serves the tracker from your own domain, records crawler reads and sends events from your backend, on any runtime.

Source: https://invisit.dev/docs/server/sdk

[`@invisitdev/server`](https://invisit.dev/docs/reference/server.md) is one client with three jobs:

- **First-party proxy.** Serves the tracker and its endpoints from your own domain, under `/_i` by default. Blocklists that match analytics by third-party host never see one, and the collector uses each visitor's real address instead of your server's.
- **Crawler reads.** Records search engines, AI crawlers and link previews, which never run JavaScript.
- **Server-side events.** `track()` from a route handler, attributed to the visit the visitor's browser started.

It is built on web-standard `Request`, `Response` and `fetch`, so it runs unchanged on Node, Bun, Deno, Cloudflare Workers and Vercel or Netlify Edge. It needs Node 20 or later, or any runtime with `fetch` and Web Crypto.

## Setup

In the dashboard, open **Settings → API keys** and create a key with **only the ingest scope**. It starts with `invisit_sk_`. Store it as a server-side environment variable, then create the client:

```sh title="Terminal"
npm install @invisitdev/server
```

```ts title="lib/invisit.ts"
import { createInvisit } from '@invisitdev/server'

export const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! })
```

> [!WARNING]
> The API key is a secret. Never put it in client code or in a `PUBLIC_` or `NEXT_PUBLIC_` variable. An ingest-only key can send events for your site but never read them, which is why it should carry no other scope.

`createInvisit` throws when the key is missing or is not an `invisit_sk_` key, so a missing variable fails at start instead of quietly dropping events.

## Proxy

Call `invisit.handle(request)` at the top of your middleware. It answers tracker requests under `/_i`, records crawler reads in the background, and resolves to `null` for everything else so your app answers it:

```ts title="src/middleware.ts"
const response = await invisit.handle(request)
if (response) return response
```

The [server frameworks](https://invisit.dev/docs/server/frameworks.md) show exactly where this goes in each framework. Then point the browser at the proxy, with the script tag:

```html title="index.html"
<script defer src="/_i/invisit.js" data-site="<site id>" data-collector="/_i"></script>
```

or with the npm package, `init({ site: '<site id>', baseUrl: '/_i' })`. To use another path, pass `{ prefix: '/stats' }` to `handle` and load `/stats/invisit.js` with `data-collector="/stats"`.

The proxy forwards only what the collector reads: user agent, language, origin, referer, Sec-Fetch headers and client hints. Cookies, `Authorization` and `Host` stay on your side, `Set-Cookie` from upstream is dropped, and bodies are capped at 1 MiB. Requests to the collector time out after 10 seconds, so a slow collector never holds your response for long.

## Visitor address

The collector needs each visitor's real address. The default reader checks these headers in order and uses the first one present: `CF-Connecting-IP`, `X-Real-IP`, `Fly-Client-IP`, `True-Client-IP`, `X-Vercel-Forwarded-For`, then the first entry of `X-Forwarded-For`.

> [!IMPORTANT]
> Those headers are only trustworthy when the platform in front of you sets or overwrites them, as Cloudflare, Vercel, Netlify and Fly do. If your server is reachable directly, a visitor can write any address there. Pass the address yourself with `ip`, or your own reader as `createInvisit({ key, clientIp: (request) => … })`.

A proxied request with no readable address counts as coming from your server, and `onError` is told once with `CLIENT_IP_UNRESOLVED`. On Node, the adapters use the socket address and drop forwarding headers unless you pass `{ trustProxy: true }`.

## Crawler reads

Search engines, AI crawlers and link-preview bots fetch your pages without running JavaScript, so no browser tracker sees them. `handle` already records them; without it, call `crawlers` from your middleware:

```ts title="src/middleware.ts"
void invisit.crawlers(request, { status: response.status })
```

It never rejects; failures go to `onError`. On serverless platforms, keep the request alive with `waitUntil` so it is not frozen before the read is sent. A request is recorded when its user agent looks like a crawler (per [`isbot`](https://www.npmjs.com/package/isbot), checked again by the collector), the method is GET or HEAD, it is a document request rather than a framework data request, and the path is not a static asset. `robots.txt` and sitemaps are kept.

`handle` runs before your app, so it records every read as 200. To record what your server actually answered, such as the 404s a crawler keeps hitting, use `proxy` before your app and `crawlers` after it, as the [Hono](https://invisit.dev/docs/server/frameworks.md#hono) and [Astro](https://invisit.dev/docs/server/frameworks.md#astro) guides do.

Crawler reads are free on every plan, up to a fair-use line of three times the plan's events. See [Billing](https://invisit.dev/docs/guides/billing.md#what-counts-as-an-event).

## Server-side events

When the thing worth counting happens on your server, send it from there. The event joins the visit the visitor's browser started, so it shows up with the right source, page and country.

```ts title="app/api/checkout/route.ts"
import { invisit } from '@/lib/invisit'

export async function POST(request: Request) {
  // … the purchase
  await invisit
    .track('purchase', {
      request,
      url: 'https://shop.example.com/checkout',
      properties: { plan: 'pro', amount: 49 },
      userId: user.id,
    })
    .catch(console.error) // a lost event must not fail the purchase
  return Response.json({ ok: true })
}
```

> [!IMPORTANT]
> `request` must be **the visitor's own request**, sent from their browser through your server. Its address, user agent, `Accept-Language` and `Sec-GPC` are what tie the event to the visit. A webhook from a payment provider is not a visitor's request.

- **Idempotency.** Every event gets a UUID v7 id. Pass your own `eventId` to make retries safe: the same id again resolves as a `duplicate`, not a second event.
- **Bots and opt-outs.** A crawler user agent resolves to `{ accepted: 0 }`, exactly as in the browser. So does a visitor sending `Sec-GPC: 1`, when `createInvisit` has `respectGpc` on.
- **Never fail your route on analytics.** `track` rejects on any failure with an [`InvisitError`](https://invisit.dev/docs/reference/server.md#errors). Always catch it where you call it.

Where there is no request of the visitor's to hand over, such as a background job or a [Convex action](https://invisit.dev/docs/server/frameworks.md#convex), pass what was kept of it as `visitor: { ip, userAgent }` instead. Every option of `track` is in the [reference](https://invisit.dev/docs/reference/server.md#track).
