Quickstart: Server-side (Node)

Capture trusted server-side events from Node.js, Next.js server actions, route handlers, and edge functions with the oakdata-node SDK - and catch AI crawlers with the request middleware.

oakdata-node is the server-side companion to oakdata-js. Use the browser SDK for pageviews, autocapture, and replay; use oakdata-node for trusted server-side eventsthe browser can't see or shouldn't be trusted to report - signups, payments, plan changes, background jobs. Both send to the same project, so client and server events resolve to one timeline per user.

In a hurry? Paste this prompt into Claude Code, Cursor, or any agent working in your repo, and give it your secret key when asked:

Set up with your AI agent
Paste into Claude Code, Cursor, or any agent in your repo

Set up server-side OakData analytics in this Node.js project using the oakdata-node SDK.

  1. 1

    Install the package: run "npm install oakdata-node".

  2. 2

    Add OAK_SECRET_KEY=<my secret key, starts with oak_sec_> to the server-only env file. Ask me for the value. Never expose it to the client - no NEXT_PUBLIC_ or VITE_ prefix.

  3. 3

    Create a small client factory (e.g. lib/oak.ts) that returns new OakClient(process.env.OAK_SECRET_KEY, { host: 'https://oakdata.co', flushAt: 1, flushInterval: 0 }) - that config is for serverless/short-lived functions. If this is a long-running server (Express, worker), instead create one shared OakClient with default options and call oak.shutdown() on SIGTERM.

  4. 4

    In my key business-logic paths (signup completed, checkout/payment completed, plan changed), call oak.capture({ distinctId: userId, event: '<snake_case_event>', properties: { ... } }). In short-lived functions, await oak.shutdown() before returning so events flush.

  5. 5

    Use the same user id the browser SDK passes to oak.identify() so client and server events stitch to one user.

Don't add any other analytics providers. Show me the changes before applying them.

Your agent will ask for your key.

Prefer to wire it up yourself? The steps below are exactly what that prompt does.

1. Install the package

npm install oakdata-node

Requires Node 18+ (for the built-in fetch and crypto.randomUUID).

2. Create a secret key

The server SDK authenticates with a secret key (oak_sec_…), created under Project → Settings → API keys. It's shown once on creation - store it immediately.

.env
bash
# Server-only - never prefix this with NEXT_PUBLIC_
OAK_SECRET_KEY=oak_sec_xxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_OAK_HOST=https://oakdata.co

Keep secret keys server-side

A secret key can write events for your whole project with no domain check. Never put it in browser code or a NEXT_PUBLIC_ variable. If one leaks, revoke it on the API keys page and create a new one.

3. Create a client

Make a small factory you can import anywhere. Next.js server functions are short-lived, so set flushAt: 1 and flushInterval: 0 - events send immediately instead of waiting in a batch that may never flush before the function returns.

lib/oak.ts
ts
import { OakClient } from 'oakdata-node'

export function oakClient() {
  return new OakClient(process.env.OAK_SECRET_KEY!, {
    host: process.env.NEXT_PUBLIC_OAK_HOST,
    flushAt: 1,
    flushInterval: 0,
  })
}

4. Capture from the App Router

Call capture from a server action or route handler, then await oak.shutdown() so the event flushes before the function returns.

app/actions.ts
ts
'use server'
import { oakClient } from '@/lib/oak'

export async function upgradePlan(userId: string) {
  // ...perform the upgrade...
  const oak = oakClient()
  oak.capture({
    distinctId: userId,
    event: 'plan_upgraded',
    properties: { plan: 'pro', mrr: 49 },
  })
  await oak.shutdown()
}
app/api/checkout/route.ts
ts
import { oakClient } from '@/lib/oak'

export async function POST(req: Request) {
  const { userId, amount } = await req.json()
  const oak = oakClient()
  oak.capture({ distinctId: userId, event: 'checkout_completed', properties: { amount } })
  await oak.shutdown()
  return Response.json({ ok: true })
}

5. Capture from the Pages Router

pages/api/track.ts
ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { OakClient } from 'oakdata-node'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const oak = new OakClient(process.env.OAK_SECRET_KEY!, {
    host: process.env.NEXT_PUBLIC_OAK_HOST,
    flushAt: 1,
    flushInterval: 0,
  })
  oak.capture({ distinctId: req.body.userId, event: 'newsletter_signup' })
  await oak.shutdown()
  res.status(200).json({ ok: true })
}

6. Long-running servers

In a persistent process (Express, a worker, a queue consumer), create the client once and let it batch in the background. Flush on graceful shutdown so nothing is lost.

server.ts
ts
import { OakClient } from 'oakdata-node'

// Batches: flushes at 20 events or every 10s by default.
const oak = new OakClient(process.env.OAK_SECRET_KEY!)

process.on('SIGTERM', async () => {
  await oak.shutdown()
  process.exit(0)
})

7. Catch AI crawlers with the middleware

AI crawlers - GPTBot, ClaudeBot, PerplexityBot - never run JavaScript, so the browser SDK can't see them. Capturing requests on the server is the only way to surface (and verify) that traffic. The middleware does it in one line: a $pageviewper inbound page request, with the visitor's user-agent and IP forwarded automatically so OakData can classify and verify the crawler.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { oakClient } from '@/lib/oak'

export function middleware(request: NextRequest) {
  oakClient().trackRequest(request)
  return NextResponse.next()
}

Static assets (.js, .css, images, /_next/…) and non-GET/HEAD requests are skipped by default, and anonymous requests collapse into one visitor per IP + user-agent - so a crawler shows up as a single row. Capturing your own events instead? Forward the two fields by hand to get the same verification:

manual
ts
oak.capture({
  distinctId,
  event: '$pageview',
  userAgent: req.headers['user-agent'],   // classify bot traffic
  ip: req.headers['x-forwarded-for'],     // verify GPTBot / Googlebot claims
})

See bot handling for how the signals combine, and bots & data quality for what shows up in the dashboard.

Methods

Identity matches the browser SDK: pass the same distinctId you use with oak.identify(userId) on the client, and server and client events stitch to one user. See identity resolution.

API
ts
oak.capture({ distinctId, event, properties?, groups?, timestamp?, userAgent?, ip? })
oak.identify({ distinctId, properties? })          // properties = user traits
oak.alias({ distinctId, alias })                   // link a new id
oak.groupIdentify({ groupType, groupKey, properties?, distinctId? })
oak.trackRequest(request, options?)                // Next.js middleware / Web Request
oak.expressMiddleware(options?)                     // Express / Connect: app.use(...)
await oak.flush()                                  // send queued events now
await oak.shutdown()                               // flush + stop the timer

Client options

OptionTypeDescription
hoststringOakData host events are sent to. Posts to ${host}/api/oak/ingest. Defaults to https://oakdata.co.
flushAtnumberFlush once this many events are queued. Default 20. Use 1 in serverless functions to send immediately.
flushIntervalnumberFlush automatically every N milliseconds. Default 10000. Set 0 to disable the timer.
requestTimeoutnumberPer-request network timeout in ms. Default 10000.
maxRetriesnumberRetry attempts for a failed batch (network, 429, or 5xx) with exponential backoff. Default 3.
disabledbooleanWhen true, every call is a no-op - handy for tests or gating analytics by environment. Default false.
debugbooleanLog queueing, flushes, and errors to the console. Default false.
ingestPathstringOverride the ingest path (rarely needed - useful behind a reverse proxy). Default /api/oak/ingest.
fetchtypeof fetchCustom fetch implementation. Defaults to the global fetch.

Capture never throws

Analytics can't take down your app: capture errors are swallowed, and transient transport failures retry with backoff (visible with debug: true). No try/catch needed.