From d54ce3ae135904193d432b71fadc23b1ba5cda44 Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Fri, 5 Jun 2026 10:51:12 -0400 Subject: [PATCH] feat: support analytics().sendEvent() from pages router api routes analytics() now accepts an optional request (NextRequest | NextApiRequest). Pages Router API routes pass req, since next/headers throws there; App Router keeps calling it with no args. Standalone routes with no page render (email pixels, redirects) send without a parent page-view. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 ++++++++ packages/core/src/server.tsx | 149 ++++++++++++++++++++++++++--------- packages/core/src/types.ts | 16 +++- 3 files changed, 158 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 25c762c..ca95158 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,39 @@ export { middleware }; That's it. Every page view is now tracked server-side. +## Custom server events + +Use `analytics().sendEvent()` to emit your own events from the server. + +In the App Router (server components, server actions, route handlers) call it with no argument — the +request context is read from `next/headers`: + +```typescript +import { analytics } from "@/nextlytics"; + +export async function signUp(formData: FormData) { + "use server"; + // ...create the user... + await (await analytics()).sendEvent("signup", { props: { plan: "pro" } }); +} +``` + +In **Pages Router API routes** `next/headers` is unavailable, so pass the request object: + +```typescript +import type { NextApiRequest, NextApiResponse } from "next"; +import { analytics } from "@/nextlytics"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await (await analytics(req)).sendEvent("email_opened", { props: { campaign: "welcome" } }); + res.status(200).end(); +} +``` + +This also works for standalone routes with no preceding page render (e.g. email pixels or redirect +endpoints) — the event is sent without a parent page-view. A NextRequest (App Router route handler) +may be passed the same way. + ## Pages Router Nextlytics also supports the Pages Router. The middleware works the same way, but instead of diff --git a/packages/core/src/server.tsx b/packages/core/src/server.tsx index 5fe93c9..e664848 100644 --- a/packages/core/src/server.tsx +++ b/packages/core/src/server.tsx @@ -1,7 +1,14 @@ import type { ReactNode } from "react"; +import type { NextRequest } from "next/server"; +import type { NextApiRequest } from "next"; +import type { RequestCookies } from "next/dist/server/web/spec-extension/cookies"; import { headers, cookies } from "next/headers"; import { removeSensitiveHeaders } from "./headers"; -import { headerNames, restoreServerComponentContext } from "./server-component-context"; +import { + headerNames, + restoreServerComponentContext, + LAST_PAGE_RENDER_ID_COOKIE, +} from "./server-component-context"; import { resolveAnonymousUser } from "./anonymous-user"; import { NextlyticsClient } from "./client"; import type { @@ -268,16 +275,25 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { ); } - const analytics = async () => { - const headersList = await headers(); - const cookieStore = await cookies(); - const pageRenderId = headersList.get(headerNames.pageRenderId); - - const serverContext = createServerContextFromHeaders(headersList); + const analytics = async (req?: NextRequest | NextApiRequest) => { + // App Router (no `req`) reads context from `next/headers`. Pages Router API + // routes pass `req` (NextApiRequest) since `next/headers` throws there; App + // Router Route Handlers may pass `req` (NextRequest) too. + const source = req ? normalizeRequest(req) : await normalizeFromNextHeaders(); + + // Link the event to the page render that triggered it, when there is one. + // Standalone routes (e.g. email pixels) have no page render — parentEventId + // is omitted in that case rather than failing. + const pageRenderId = + source.headers.get(headerNames.pageRenderId) || + source.cookies.get(LAST_PAGE_RENDER_ID_COOKIE)?.value || + undefined; + + const serverContext = buildServerContext(source); const ctx: RequestContext = { - headers: headersList, - cookies: cookieStore, - path: headersList.get(headerNames.pathname) || "", + headers: source.headers, + cookies: source.cookies, + path: source.path, }; // Resolve anonymous user ID @@ -300,7 +316,10 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { eventName: string, opts?: { props?: Record } ): Promise<{ ok: boolean }> => { - if (!pageRenderId) { + // In the App Router no-arg path a missing pageRenderId means middleware + // never ran — keep that as a hard error. With an explicit `req` the + // caller built the context themselves, so a missing render id is fine. + if (!pageRenderId && !req) { console.error("[Nextlytics] analytics() requires nextlyticsMiddleware"); return { ok: false }; } @@ -308,7 +327,7 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { const event: NextlyticsEvent = { origin: "server", eventId: generateId(), - parentEventId: pageRenderId, + ...(pageRenderId ? { parentEventId: pageRenderId } : {}), type: eventName, collectedAt: new Date().toISOString(), anonymousUserId, @@ -332,37 +351,95 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { }; } -function createServerContextFromHeaders( - headersList: Awaited> -): ServerEventContext { - const rawHeaders: Record = {}; - headersList.forEach((value, key) => { - rawHeaders[key] = value; +/** Request data `analytics()` needs, normalized across its three sources: + * App Router (`next/headers`), App Router Route Handlers (NextRequest), and + * Pages Router API routes (NextApiRequest). */ +type NormalizedRequest = { + headers: Headers; + cookies: Pick; + path: string; + search: Record; + method: string; +}; + +function searchToRecord(params: URLSearchParams): Record { + const out: Record = {}; + params.forEach((value, key) => { + (out[key] ??= []).push(value); }); - const requestHeaders = removeSensitiveHeaders(rawHeaders); + return out; +} - const pathname = headersList.get(headerNames.pathname) || ""; - const search = headersList.get(headerNames.search) || ""; - const searchParams: Record = {}; +/** NextRequest exposes a Web `Headers` (has `.get`); NextApiRequest exposes a + * plain `IncomingHttpHeaders` object (no `.get`). */ +function isNextApiRequest(req: NextRequest | NextApiRequest): req is NextApiRequest { + return typeof (req.headers as { get?: unknown })?.get !== "function"; +} - if (search) { - const params = new URLSearchParams(search); - params.forEach((value, key) => { - if (!searchParams[key]) { - searchParams[key] = []; - } - searchParams[key].push(value); - }); +async function normalizeFromNextHeaders(): Promise { + const [_cookies, _headers] = await Promise.all([cookies(), headers()]); + return { + headers: _headers, + cookies: _cookies, + path: _headers.get(headerNames.pathname) || "", + search: searchToRecord(new URLSearchParams(_headers.get(headerNames.search) || "")), + method: "GET", + }; +} + +function normalizeRequest(req: NextRequest | NextApiRequest): NormalizedRequest { + if (!isNextApiRequest(req)) { + return { + headers: req.headers, + cookies: req.cookies, + path: req.nextUrl.pathname, + search: searchToRecord(req.nextUrl.searchParams), + method: req.method, + }; } + // Pages Router API route (req/res). + const headersList = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headersList.set(key, Array.isArray(value) ? value.join(", ") : value); + } + } + + const cookieMap = req.cookies || {}; + const cookieStore: Pick = { + get: (name: string) => { + const value = cookieMap[name]; + return value === undefined ? undefined : { name, value }; + }, + getAll: () => Object.entries(cookieMap).map(([name, value]) => ({ name, value })), + has: (name: string) => name in cookieMap, + } as Pick; + + const url = new URL(req.url || "/", `http://${headersList.get("host") || "localhost"}`); + return { + headers: headersList, + cookies: cookieStore, + path: url.pathname, + search: searchToRecord(url.searchParams), + method: req.method || "GET", + }; +} + +function buildServerContext(source: NormalizedRequest): ServerEventContext { + const rawHeaders: Record = {}; + source.headers.forEach((value, key) => { + rawHeaders[key] = value; + }); + return { collectedAt: new Date(), - host: headersList.get("host") || "", - method: "GET", - path: pathname, - search: searchParams, - ip: headersList.get("x-forwarded-for")?.split(",")[0]?.trim() || "", - requestHeaders, + host: source.headers.get("host") || "", + method: source.method, + path: source.path, + search: source.search, + ip: source.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "", + requestHeaders: removeSensitiveHeaders(rawHeaders), responseHeaders: {}, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e3260c7..25e5d14 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -97,7 +97,8 @@ export interface NextlyticsEvent { } import type { RequestCookies } from "next/dist/server/web/spec-extension/cookies"; -import type { NextMiddleware } from "next/server"; +import type { NextMiddleware, NextRequest } from "next/server"; +import type { NextApiRequest } from "next"; export type AnonymousUserResult = { /** Anonymous user identifier */ @@ -251,7 +252,7 @@ export type NextlyticsBackendFactory = (ctx: RequestContext) => NextlyticsBacken /** Server-side analytics API */ export type NextlyticsServerSide = { - /** Send custom event from server component/action */ + /** Send custom event from a server component, server action, or API route */ sendEvent: ( eventName: string, opts?: { props?: Record } @@ -299,8 +300,15 @@ export type ClientRequestResult = { /** Return value from Nextlytics() */ export type NextlyticsResult = { - /** Get server-side analytics API */ - analytics: () => Promise; + /** Get server-side analytics API. + * + * App Router (server components, server actions, route handlers): call with no + * argument — context is read from `next/headers`. + * + * Pages Router API routes: `next/headers` is unavailable there, so pass the + * request (`analytics(req)`). A NextRequest (App Router route handler) is also + * accepted. */ + analytics: (req?: NextRequest | NextApiRequest) => Promise; /** Middleware to intercept requests */ middleware: NextMiddleware; /** Manually dispatch event (returns two-phase result) */