EventRail

Open source event tracking that runs inside your app.

Track from the browser or the server, with the same session and the user your server knows. Everything lands in your own database.

Next.js

npm install @eventrail/nextjs

Starting with Next.js. More frameworks on the way.

Many events that matter happen on your server. Capture them there.

The browser only sees the click. Your server knows whether the upgrade actually went through. Track it where it happens, while keeping the visitor's context.

In the browser

app/pricing/upgrade-button.tsx

Next.js · app/pricing/upgrade-button.tsx

"use client";
import { track } from "@/lib/eventrail-client";
import { upgradePlan } from "./actions";

export function UpgradeButton() {
  return (
    <button
      onClick={() => {
        track("upgrade_plan_button_clicked", { plan: "pro" });
        upgradePlan("pro");
      }}
    >
      Upgrade to Pro
    </button>
  );
}

Example output

Press ▶ Run to see the record this click produces.

{
  name: "upgrade_plan_button_clicked",
  producerType: "remote",Came from the browser.
  location: "https://yourapp.com/pricing",
  sessionId: "8f3c…a1",
  consent: "accepted",
  producerProperties: { plan: "pro" },
  hubProperties: { userId: "usr_42", plan: "free" }
}

On your server

app/pricing/actions.ts

Next.js · app/pricing/actions.ts

"use server";
import { track } from "@/lib/eventrail-server";
import { billing, errorCode } from "@/lib/billing"; // your code

export async function upgradePlan(plan: string) {
  // Called from the browser, runs on the server:
  try {
    await billing.upgrade(plan);
    track("plan_upgraded", { plan });
  } catch (err) {
    track("upgrade_plan_failed", { plan, reason: errorCode(err) });
    throw err;
  }
}

Example output

Press ▶ Run (success) or ▶ Run (failure) to see the record the server action produces.

Run (success)

{
  name: "plan_upgraded",
  producerType: "local",Tracked on your server.
  location: "https://yourapp.com/pricing",
  sessionId: "8f3c…a1",
  consent: "accepted",
  producerProperties: { plan: "pro" },
  hubProperties: { userId: "usr_42", plan: "pro" }
}

Run (failure)

{
  name: "upgrade_plan_failed",
  producerType: "local",
  location: "https://yourapp.com/pricing",
  sessionId: "8f3c…a1",
  consent: "accepted",
  producerProperties: { plan: "pro", reason: "card_declined" },
  hubProperties: { userId: "usr_42", plan: "free" }
}

Server events keep the visitor's session and consent. Browser events get the user your server knows. Same shape, same table.

  • userId and plan come from your Hub's hubProperties(), which reads the signed-in user on every event. How to set it up
  • Records are shortened. Each also has id, specVersion, producerName and three timestamps, and by default the Hub adds userAgent, forwardedFor and acceptLanguage to hubProperties.

Event data your data analyst actually enjoys working with.

  • Strongly typed schema.Every row has the same typed, versioned columns (specVersion), with TypeScript types in the package. producerProperties and hubProperties are JSON you define.
  • Understandable relations.Link events by sessionId, deduplicate on id, order on receivedAt. EventRecord reference →
  • Raw but rich.No pre-aggregation, no sampling. Every row carries its context: location, consent, and whatever your server adds in hubProperties.

A daily funnel over the events above, in Postgres, using the table and snake_case columns the Postgres destination creates.

funnel.sql
SELECT
  received_at::date AS day,
  COUNT(DISTINCT session_id) FILTER (
    WHERE name = 'page_view' AND location LIKE 'https://yourapp.com/pricing%'page_view is recorded by the TrackPageViews component; location is the page URL.
  ) AS viewed_pricing,
  COUNT(DISTINCT session_id) FILTER (
    WHERE name = 'upgrade_plan_button_clicked'
  ) AS clicked_upgrade,
  COUNT(DISTINCT session_id) FILTER (
    WHERE name = 'plan_upgraded' AND producer_type = 'local'
  ) AS upgraded,
  COUNT(DISTINCT session_id) FILTER (
    WHERE name = 'upgrade_plan_failed' AND producer_type = 'local'
  ) AS failed
FROM eventrail_events_v1
GROUP BY day
ORDER BY day;

Filter conversions on producer_type = 'local' and a forged browser request can never inflate them. Only your server can produce a local event.

Example output

Press ▶ Run to see an example result.

dayviewed_pricingclicked_upgradeupgradedfailed
2026-09-1719850464
2026-09-1817644413
2026-09-1912129272
2026-09-2010425232
2026-09-2120753485
2026-09-2222157525
2026-09-2321354504
Funnel, all 7 days
  1. Viewed pricing1,240
  2. Clicked upgrade312 · 25.2% of viewed
  3. Upgraded287 · 92.0% of clicked
  4. Failed25 · 8.0% of clicked

There is nothing to host.

How an event travels through EventRailA Producer in your app's browser runtime posts events over HTTP to the Hub. A Producer in your app's server runtime hands events to the Hub in-process. The Hub assembles an EventRecord and hands it to a Destination, also inside your app, which writes it to storage you own.Your app · browser runtimeYour app · server runtimeHTTP POSTin-processEventRecordwrite()Producerin the browserProduceron your serverHubin your appDestinationPostgres, BigQuery, …Your storagea database you own

The Hub runs in your own server runtime. That's why it can read your session cookie and your auth, which a separate analytics service never can. No extra container, no dashboard to keep alive, no third-party origin to proxy.

Choose how the session identifier is stored: enabled, disabled, optIn or optOut. Wire the consent setter to your banner. Every row records the consent state it was collected under.

lib/eventrail-client.ts

Next.js · lib/eventrail-client.ts

"use client";
import { createBrowserProducer } from "@eventrail/nextjs/client";

export const producer = createBrowserProducer({
  collectorLocation: "/api/eventrail",
  storeSessionId: "optIn", // or "enabled", "disabled", "optOut"
});

export const { track } = producer;
app/consent-banner.tsx

Next.js · app/consent-banner.tsx

"use client";
import { producer } from "@/lib/eventrail-client";

export function ConsentBanner() {
  const consent = producer.useConsent();
  if (consent !== "unset") return null;

  return (
    <section aria-label="Cookie consent">
      <button onClick={() => producer.setConsent("accepted")}>Accept</button>
      <button onClick={() => producer.setConsent("denied")}>Decline</button>
    </section>
  );
}

When EventRail is the right choice

  • Your app has a backend.Browser-only trackers see the click and guess the rest. EventRail records whether the upgrade actually went through, from the server that knows.
  • You want the raw events in your own database.Not in a vendor's store behind an export API. Every row is yours to query with SQL, next to the rest of your data.
  • You want every event tied to the signed-in user.A separate analytics service can't read your session or auth. The Hub runs inside your app, so it can.
  • You want nothing extra to run.No self-hosted analytics stack, no extra container, no third-party origin to proxy. Just a package in your app.

See how EventRail compares →

Made in the Netherlands

Bas Dunn

I'm Bas Dunn, and I build EventRail from the Netherlands. In my data work I kept being handed Google Analytics data to work with, and I was never happy with it. So I built the open source alternative I wanted myself: event data that starts in your app and lands in your own database.

EventRail is where my engineering and analytics backgrounds come together: built by someone who writes the tracking code and then has to query what it produces.

Want to work with me on your data setup? Book a consult via ReturnOnCode.

Get started

Your first event is four steps away.

Install the package, then follow the guide from the Hub to your first tracked event.

Get started
  1. Create the Hub
  2. Add the collector route
  3. Add the browser producer
  4. Fire your first event