On your server

Server SDK

.mdllms.txt

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

@invisitdev/server 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:

Terminal
npm install @invisitdev/server
lib/invisit.ts
import { createInvisit } from '@invisitdev/server'

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

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:

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

The server frameworks show exactly where this goes in each framework. Then point the browser at the proxy, with the script tag:

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.

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:

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, 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 and 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.

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.

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 })
}
  • 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. 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, pass what was kept of it as visitor: { ip, userAgent } instead. Every option of track is in the reference.