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/nextjsStarting 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
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
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.
userIdandplancome from your Hub'shubProperties(), which reads the signed-in user on every event. How to set it up- Records are shortened. Each also has
id,specVersion,producerNameand three timestamps, and by default the Hub addsuserAgent,forwardedForandacceptLanguagetohubProperties.
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.producerPropertiesandhubPropertiesare JSON you define. - Understandable relations.Link events by
sessionId, deduplicate onid, order onreceivedAt. 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.
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.
| day | viewed_pricing | clicked_upgrade | upgraded | failed |
|---|---|---|---|---|
| 2026-09-17 | 198 | 50 | 46 | 4 |
| 2026-09-18 | 176 | 44 | 41 | 3 |
| 2026-09-19 | 121 | 29 | 27 | 2 |
| 2026-09-20 | 104 | 25 | 23 | 2 |
| 2026-09-21 | 207 | 53 | 48 | 5 |
| 2026-09-22 | 221 | 57 | 52 | 5 |
| 2026-09-23 | 213 | 54 | 50 | 4 |
- Viewed pricing1,240
- Clicked upgrade312 · 25.2% of viewed
- Upgraded287 · 92.0% of clicked
- Failed25 · 8.0% of clicked
There is nothing to host.
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.
Consent handling built in, not bolted on.
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.
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;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.
Made in the Netherlands
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