This reference shows two production patterns:
- a Next.js App Router endpoint for browser-originated analytics
- a Node.js service sender behind a durable outbox or queue
Keep the GraphJSON workspace key server-side in both.
Architecture#
browser
→ Next.js first-party route
→ allowlisted public contract
→ GraphJSON
completed server transaction
→ transactional outbox
→ Node worker
→ GraphJSON bulk API
Use direct delivery for disposable interaction analytics. Use an outbox or durable queue for facts that must reconcile.
Configure server-only values#
GRAPHJSON_API_KEY=
GRAPHJSON_COLLECTION=product_events
Never prefix the API key with NEXT_PUBLIC_.
Validate configuration when the server module is loaded:
import "server-only";
const apiKey = process.env.GRAPHJSON_API_KEY;
const collection =
process.env.GRAPHJSON_COLLECTION || "product_events";
if (!apiKey) {
throw new Error("GRAPHJSON_API_KEY is required");
}
Do not log the configuration object on startup.
Build one transport function#
import "server-only";
type EventInput = {
event_id: string;
occurred_at: number;
payload: Record<string, unknown>;
};
export async function sendGraphJSONEvent(input: EventInput) {
const response = await fetch(
"https://api.graphjson.com/api/log",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: apiKey,
collection,
timestamp: input.occurred_at,
json: JSON.stringify(input.payload)
}),
signal: AbortSignal.timeout(5_000),
cache: "no-store"
}
);
const body = await response.text();
if (!response.ok) {
const error = new Error(
`GraphJSON ${response.status}: ${body.slice(0, 300)}`
);
Object.assign(error, { status: response.status });
throw error;
}
}
The caller decides retry policy. A transport helper should not retry an invalid
400 automatically.
Accept a public browser event#
Use an allowlisted public contract:
// app/api/analytics/route.ts
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { sendGraphJSONEvent } from "@/lib/graphjson";
export const runtime = "nodejs";
const publicEvent = z.object({
event: z.enum([
"pricing_viewed",
"signup_started"
]),
anonymous_id: z.string().uuid(),
path: z.enum(["/pricing", "/signup"]),
occurred_at: z.number().int().nonnegative()
}).strict();
export async function POST(request: NextRequest) {
const parsed = publicEvent.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid_event" },
{ status: 400 }
);
}
const session = await getOptionalSession(request);
const payload = {
...parsed.data,
event_id: randomUUID(),
user_id: session?.user.id,
account_id: session?.activeAccount.id,
environment: process.env.VERCEL_ENV || "development",
schema_version: 1
};
try {
await sendGraphJSONEvent({
event_id: payload.event_id,
occurred_at: payload.occurred_at,
payload
});
} catch (error) {
console.error("analytics_delivery_failed", {
event: payload.event,
event_id: payload.event_id,
reason:
error instanceof Error ? error.message : "unknown"
});
}
return new NextResponse(null, { status: 202 });
}
getOptionalSession represents your application authentication. Derive
user_id and account_id there; never trust browser-provided tenant IDs.
Do not return the GraphJSON key or raw provider error.
Apply abuse controls#
A public first-party endpoint needs:
- allowed origins where applicable
- request-size limit
- rate limit by an appropriate non-secret key
- small event-name enum
- strict property schema
- bot/internal traffic policy
- consent check
Do not accept:
{
"event": "anything",
"properties": {"anything": "anything"}
}
That becomes a public arbitrary-write proxy to the workspace.
Keep analytics non-blocking#
For an exploratory interaction, returning success to the product flow is usually more important than analytics delivery.
Do not make signup, checkout, or report generation fail because the analytical copy failed.
But do not confuse “non-blocking” with “unobserved.” Count failures and alert when the producer becomes unhealthy.
Write important facts to an outbox#
Inside the business transaction:
await database.transaction(async (tx) => {
const order = await tx.order.create({
data: approvedOrder
});
await tx.analyticsOutbox.create({
data: {
event_id: `order:${order.id}:completed`,
occurred_at: Math.floor(order.completedAt.getTime() / 1000),
collection: "order_events",
payload: {
event: "order_completed",
event_id: `order:${order.id}:completed`,
account_id: order.accountId,
order_id: order.id,
amount_minor: order.amountMinor,
currency: order.currency,
schema_version: 1
}
}
});
});
The exact transaction API depends on your database library.
The outbox row and business record must commit together. The worker can then deliver without holding up the request.
Send bulk batches from a Node worker#
async function sendGraphJSONBatch(rows: OutboxRow[]) {
const response = await fetch(
"https://api.graphjson.com/api/bulk-log",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: apiKey,
collection: rows[0].collection,
timestamps: rows.map((row) => row.occurred_at),
jsons: rows.map((row) =>
JSON.stringify(row.payload)
)
}),
signal: AbortSignal.timeout(10_000)
}
);
const body = await response.text();
if (!response.ok) {
throw Object.assign(
new Error(
`GraphJSON ${response.status}: ${body.slice(0, 300)}`
),
{ status: response.status }
);
}
}
Worker loop:
async function processOutbox() {
const rows = await claimOutboxRows({
limit: 50,
leaseSeconds: 60
});
if (rows.length === 0) return;
try {
await sendGraphJSONBatch(rows);
await markDelivered(rows.map((row) => row.id));
} catch (error) {
const status =
typeof error === "object" && error
? (error as { status?: number }).status
: undefined;
if (status === 400) {
await quarantine(rows, "invalid_graphjson_request");
return;
}
await rescheduleWithBackoff(rows);
}
}
Claims must prevent two workers from processing the same rows simultaneously. Retries must preserve event IDs and occurrence timestamps.
Handle shutdown#
For a long-running Node worker:
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
});
while (!stopping) {
await processOutbox();
await new Promise((resolve) => setTimeout(resolve, 500));
}
await releaseOutstandingLeases();
Use the hosting platform’s actual shutdown budget. Do not start an unbounded batch after shutdown begins.
Serverless invocations should claim a bounded amount of work and finish inside the invocation deadline.
Test the producer#
Unit test the exact event:
expect(buildOrderCompleted(order)).toEqual({
event: "order_completed",
event_id: "order:ord_1:completed",
account_id: "acct_7",
order_id: "ord_1",
amount_minor: 4900,
currency: "usd",
schema_version: 1
});
Route tests should verify:
- unknown event rejected
- extra property rejected
- tenant ID derived from session
- GraphJSON failure does not expose details
- consent policy enforced
- payload remains under the size budget
Worker tests should verify:
- successful batch acknowledged once
400quarantined429, timeout, and500rescheduled- event IDs unchanged on retry
- mixed collections never share one batch
- shutdown releases leases
Deployment checklist#
- API key is server-only.
- Public route accepts a strict allowlist.
- Tenant identity comes from authentication.
- Direct analytics cannot fail the product action.
- Important facts use a durable outbox.
- HTTP requests have timeouts.
- Retries are status-aware and bounded.
- Bulk batches contain one collection and at most 50 events.
- Logs contain IDs and safe reason codes, not payloads or keys.
- Preview and production use separate environments.
- Worker shutdown behavior is tested.
Continue with Executable event contracts, Reliable event delivery, and High-volume ingestion.