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

> The invisit proxy and crawler reads in Next.js, TanStack Start, Nuxt, Nitro, Hono, Elysia, Effect, Express, NestJS, Fastify, Workers and more.

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

Every guide calls [`@invisitdev/server`](https://invisit.dev/docs/server/sdk.md) from your middleware and assumes the client from [its setup](https://invisit.dev/docs/server/sdk.md#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`.

```ts title="proxy.ts"
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.

```ts title="src/start.ts"
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`.

```ts title="server/middleware/invisit.ts"
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.

```ts title="middleware/invisit.ts"
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`.

```ts title="src/hooks.server.ts"
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
}
```

> [!WARNING]
> With adapter-node, set `kit.paths.origin` (SvelteKit 3) or the `ORIGIN` variable (SvelteKit 2) when serving over plain HTTP or behind a proxy that changes `Host`. Otherwise SvelteKit's CSRF check answers the tracker's POSTs with a 403.

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

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

let client: Invisit | undefined

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

```ts title="src/middleware.ts"
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
})
```

> [!WARNING]
> A prerendered 404 page, which is Astro's default, answers `/_i/*` before the middleware runs. Add `export const prerender = false` to `src/pages/404.astro`. With Starlight, set `disable404Route: true` and add that page yourself.

> [!WARNING]
> With the Node adapter, name your public host in `security.allowedDomains`. Without it, Astro builds every request URL on `localhost`, and POSTs whose `Origin` is your real domain get a CSRF 403.

## React Router and Remix

A splat resource route, registered in `app/routes.ts` as `route('_i/*', 'routes/[_i].$.ts')`:

```ts title="app/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 = proxy
```

`react-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):

```ts title="src/middleware.ts"
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:

```ts title="src/middleware/invisit.ts"
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

```ts title="src/index.ts"
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

```ts title="src/index.ts"
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.

```ts title="src/invisit-middleware.ts"
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

```ts title="src/server.ts"
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()`:

```ts title="src/main.ts"
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:

```ts title="src/main.ts"
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

```ts title="src/server.ts"
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

```ts title="src/worker.ts"
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](https://invisit.dev/docs/server/sdk.md#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`.

```ts title="convex/analytics.ts"
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

```ts title="server.ts"
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.
