Every guide calls @invisitdev/server from your middleware and assumes the client from its setup lives in lib/invisit.ts, or your framework’s equivalent. Where no edge in front of the server sets the visitor’s address in a header (a bare Bun, Deno or Node server), the guides pass it with ip.
Once the server side is in, point the browser at it with init({ site: '<site id>', baseUrl: '/_i' }), or data-collector="/_i" on the script tag.
Next.js
In the proxy file. That is Next.js 16; before it, the file is middleware.ts with export async function middleware.
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server'
import { invisit } from './lib/invisit'
export async function proxy(request: NextRequest, event: NextFetchEvent) {
const response = await invisit.handle(request, { waitUntil: (p) => event.waitUntil(p) })
return response ?? NextResponse.next()
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }If you already have one, call invisit.handle at its top and return its response when there is one. It runs before the page renders, so crawler reads are recorded as 200.
TanStack Start
One global request middleware does both jobs: it answers tracker requests itself and records crawler reads with the status each page got.
import { createMiddleware, createStart } from '@tanstack/react-start'
import { getRequestIP } from '@tanstack/react-start/server'
import { invisit } from './lib/invisit'
const invisitMiddleware = createMiddleware().server(async ({ request, next }) => {
const proxied = await invisit.proxy(request, { ip: getRequestIP() })
if (proxied) return proxied
const result = await next()
void invisit.crawlers(request, { status: result.response.status })
return result
})
export const startInstance = createStart(() => ({ requestMiddleware: [invisitMiddleware] }))src/start.ts is not in the default template; create it beside src/router.tsx.
Nuxt
In a Nitro middleware. Create the client in server/utils/invisit.ts from useRuntimeConfig().invisitApiKey, set from NUXT_INVISIT_API_KEY.
import { invisit } from '../utils/invisit'
export default defineEventHandler(async (event) => {
if (event.path.startsWith('/_i/')) {
const ip = getRequestIP(event, { xForwardedFor: true })
return (await invisit.proxy(toWebRequest(event), { ip })) ?? undefined
}
// Nuxt's own asset and island requests are not page reads.
if (event.method === 'GET' && !/^\/_{1,2}nuxt/.test(event.path)) {
event.waitUntil(invisit.crawlers(toWebRequest(event)))
}
})getRequestIP(event, { xForwardedFor: true }) trusts X-Forwarded-For, which is only right behind a proxy that overwrites it; drop the option if visitors can reach the server directly.
Nitro
On Nitro 3, a middleware file. event.req is already a web Request; returning the proxied response ends the request there, and anything else lets it go on.
import { defineHandler } from 'nitro'
import { getRequestIP } from 'nitro/h3'
import { invisit } from '../lib/invisit'
export default defineHandler(async (event) => {
const proxied = await invisit.proxy(event.req, {
ip: getRequestIP(event, { xForwardedFor: true }),
})
if (proxied) return proxied
if (event.req.method === 'GET') event.waitUntil(invisit.crawlers(event.req))
})Nitro middleware runs before the route, so crawler reads are recorded as 200. On Nitro 2, use the Nuxt middleware above: Nuxt runs on it.
SvelteKit
In the handle hook. Written for SvelteKit 3, with the key declared in src/env.ts and imported from $app/env/private; on SvelteKit 2, import the key from $env/static/private, Handle from @sveltejs/kit and the client from $lib/server/invisit.
import type { Handle } from '@sveltejs/kit/hooks'
import { invisit } from '#lib/server/invisit.ts'
export const handle: Handle = async ({ event, resolve }) => {
const proxied = await invisit.proxy(event.request, { ip: event.getClientAddress() })
if (proxied) return proxied
const response = await resolve(event)
if (!event.isDataRequest) void invisit.crawlers(event.request, { status: response.status })
return response
}Astro
A middleware, which needs on-demand rendering: an adapter, and output: 'server' or prerender = false on the pages it covers. Astro also runs middleware while it prerenders pages at build time, where the key is usually not set, so create the client on first use:
import { createInvisit, type Invisit } from '@invisitdev/server'
let client: Invisit | undefined
export const invisit = (): Invisit =>
(client ??= createInvisit({ key: process.env.INVISIT_API_KEY! }))import { defineMiddleware } from 'astro:middleware'
import { invisit } from './lib/invisit'
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next()
const proxied = await invisit().proxy(context.request, { ip: context.clientAddress })
if (proxied) return proxied
const response = await next()
void invisit().crawlers(context.request, { status: response.status })
return response
})React Router and Remix
A splat resource route, registered in app/routes.ts as route('_i/*', 'routes/[_i].$.ts'):
import { invisit } from '~/lib/invisit.server'
const proxy = async ({ request }: { request: Request }) =>
(await invisit.proxy(request)) ?? new Response(null, { status: 404 })
export const loader = proxy
export const action = proxyreact-router-serve passes on no socket address: put a proxy in front that sets X-Forwarded-For or X-Real-IP, or serve the app from Express with middleware(invisit). For crawler reads with the real status, run npx react-router reveal entry.server and call invisit.crawlers(request, { status: responseStatusCode }) in handleRequest.
SolidStart
A middleware, registered with solidStart({ middleware: 'src/middleware.ts' }) in vite.config.ts (app.config.ts on SolidStart 1):
import { createMiddleware } from '@solidjs/start/middleware'
import { invisit } from './lib/invisit'
export default createMiddleware([
async (event, next) => {
const proxied = await invisit.proxy(event.req, { ip: event.req.ip })
if (proxied) return proxied
const response = await next()
// SolidStart's own /_build and /_server requests are not page reads.
if (!event.url.pathname.startsWith('/_')) {
void invisit.crawlers(event.req, { status: event.res.status ?? 200 })
}
return response
},
])Waku
Waku loads every file in src/middleware as Hono middleware:
import type { MiddlewareHandler } from 'hono'
import { invisit } from '../lib/invisit'
export default function invisitMiddleware(): MiddlewareHandler {
return async (c, next) => {
const proxied = await invisit.proxy(c.req.raw)
if (proxied) return proxied
await next()
void invisit.crawlers(c.req.raw, { status: c.res.status })
}
}Behind a host that sets the visitor’s address (Vercel, Netlify, Cloudflare), that is all. On a bare Node server, pass ip: getConnInfo(c).remote.address from @hono/node-server/conninfo.
Hono
import { Hono } from 'hono'
import { getConnInfo } from 'hono/bun' // Node: '@hono/node-server/conninfo', Deno: 'hono/deno'
import { invisit } from './lib/invisit'
const app = new Hono()
app.use(async (c, next) => {
const proxied = await invisit.proxy(c.req.raw, { ip: getConnInfo(c).remote.address })
if (proxied) return proxied
await next()
void invisit.crawlers(c.req.raw, { status: c.res.status })
})On Cloudflare Workers, read the key from the Worker’s bindings, drop getConnInfo and ip (CF-Connecting-IP carries the visitor), and keep the crawler read alive with c.executionCtx.waitUntil(invisit.crawlers(…)).
Elysia
import { Elysia } from 'elysia'
import { invisit } from './lib/invisit'
new Elysia()
.onRequest(
async ({ request, server }) =>
(await invisit.handle(request, { ip: server?.requestIP(request)?.address })) ?? undefined,
)
.listen(3000)Effect
A global router middleware for effect/http (Effect 4). It answers tracker requests, then records crawler reads with the status the visitor got, including the 404 a missing route turns into.
import { Effect, Exit, Option } from 'effect'
import { HttpRouter, HttpServerError, HttpServerRequest, HttpServerResponse } from 'effect/http'
import { invisit } from './lib/invisit'
export const InvisitMiddleware = HttpRouter.middleware(
(app) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const ip = Option.getOrNull(request.remoteAddress)
if (request.url.startsWith('/_i/')) {
const web = yield* HttpServerRequest.toWeb(request)
const proxied = yield* Effect.promise(() => invisit.proxy(web, { ip }))
if (proxied) return HttpServerResponse.fromWeb(proxied)
}
return yield* app.pipe(
Effect.onExit((exit) =>
Effect.gen(function* () {
const status = Exit.isSuccess(exit)
? exit.value.status
: (yield* HttpServerError.causeResponse(exit.cause))[0].status
const url = Option.getOrThrow(HttpServerRequest.toURL(request))
const read = new Request(url, { method: request.method, headers: request.headers })
void invisit.crawlers(read, { status })
}),
),
)
}),
{ global: true },
)Merge it into the layer you serve, beside your routes: HttpRouter.serve(Layer.mergeAll(Routes, InvisitMiddleware)).
Express
import express from 'express'
import { middleware } from '@invisitdev/server/node'
import { invisit } from './lib/invisit.js'
const app = express()
app.use(middleware(invisit)) // before body parsers: it reads tracker request bodies itself
app.use(express.json())Tracker requests are answered from the collector; every other request goes on to your app, and crawler reads are recorded once its response finishes, with the real status. Register it before any body parser, or it fails tracker requests with BODY_CONSUMED.
NestJS
With the Express platform (the default), register the middleware on the app in main.ts, before any app.useBodyParser(), not in a module’s configure():
import { NestFactory } from '@nestjs/core'
import { middleware } from '@invisitdev/server/node'
import { AppModule } from './app.module'
import { invisit } from './lib/invisit'
const app = await NestFactory.create(AppModule)
app.use(middleware(invisit))
await app.listen(3000)With the Fastify platform, add the Fastify hook to its instance instead:
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'
import { middleware } from '@invisitdev/server/node'
import { AppModule } from './app.module'
import { invisit } from './lib/invisit'
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())
const invisitMiddleware = middleware(invisit)
app
.getHttpAdapter()
.getInstance()
.addHook('onRequest', async (request, reply) => {
await invisitMiddleware(request.raw, reply.raw)
if (reply.raw.headersSent) reply.hijack()
})
await app.listen(3000)Fastify
import Fastify from 'fastify'
import { middleware } from '@invisitdev/server/node'
import { invisit } from './lib/invisit.js'
const fastify = Fastify({ logger: true })
const invisitMiddleware = middleware(invisit)
fastify.addHook('onRequest', async (request, reply) => {
await invisitMiddleware(request.raw, reply.raw)
if (reply.raw.headersSent) reply.hijack()
})Cloudflare Workers
import { createInvisit } from '@invisitdev/server'
export default {
async fetch(request, env, ctx) {
const invisit = createInvisit({ key: env.INVISIT_API_KEY })
const proxied = await invisit.proxy(request)
if (proxied) return proxied
const response = await fetch(request) // your origin, or your app's own handler
ctx.waitUntil(invisit.crawlers(request, { status: response.status }))
return response
},
} satisfies ExportedHandler<{ INVISIT_API_KEY: string }>Set the key with wrangler secret put INVISIT_API_KEY. A Worker routed in front of any origin, a WordPress or other PHP site included, gives it the proxy and crawler reads without touching the site itself.
Convex
Convex functions run on Convex’s servers, not your site’s, so there is nothing to proxy and no page to record a crawler on. What fits is server-side events from an action: Convex knows the address and user agent of the request that called it, and track takes them as the visitor.
import { v } from 'convex/values'
import { createInvisit } from '@invisitdev/server'
import { action } from './_generated/server'
export const trackPurchase = action({
args: { plan: v.string() },
handler: async (ctx, { plan }) => {
const { ip, userAgent } = await ctx.meta.getRequestMetadata()
// Scheduled and cron runs have no visitor behind them.
if (!ip || !userAgent) return
const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! })
await invisit
.track('purchase', { visitor: { ip, userAgent }, properties: { plan } })
.catch(console.error)
},
})Set the key with npx convex env set INVISIT_API_KEY invisit_sk_…. Mutations cannot call fetch. From one, schedule an action with ctx.scheduler.runAfter(0, …) and pass it the ip and userAgent from the mutation’s own getRequestMetadata(): a scheduled action has none of its own.
Plain node:http
import { createServer } from 'node:http'
import { sendResponse, toRequest } from '@invisitdev/server/node'
import { invisit } from './lib/invisit.js'
createServer(async (req, res) => {
if (req.url?.startsWith('/_i/')) {
const response = await invisit.proxy(await toRequest(req))
if (response) return sendResponse(res, response)
}
// … your app
})toRequest reads the request body, so only call it for requests you hand to invisit.