Install
Add analytics and revenue attribution to Pylon
A Pylon app serves SSR and /api/fn/* from one binary on one port, which changes where each piece of this install goes. There is no next.config.js to rewrite, and the beacon relay is a server function rather than a route handler — put it at app/api/fn/ingestEvent/route.ts, as the Next.js shape suggests, and it is never matched.
Serve the tracker from your own origin
A route.ts exporting GET is a raw handler: it returns a body and content type verbatim, with no React render. Point one at our script and it is served from your domain, where ad-blocker host lists don't reach. Cache it in process — this runs on every cold page load otherwise. A dotted directory name is a normal path segment in Pylon, so the route really can be /rt/track.js.
app/rt/track.js/route.tsimport type { RawRouteHandler } from "@pylonsync/react"; let cached: { body: string; at: number } | null = null; const TTL_MS = 60 * 60 * 1000; export const GET: RawRouteHandler = async () => { if (!cached || Date.now() - cached.at > TTL_MS) { const res = await fetch("https://userevtrail.com/track.js"); if (res.ok) cached = { body: await res.text(), at: Date.now() }; } return { // Never 500 a page over analytics — a no-op script is the safe fallback. body: cached?.body ?? "/* revtrail unavailable */", contentType: "application/javascript; charset=utf-8", headers: { "cache-control": "public, max-age=3600" }, }; };Relay the beacon through a public action
This is the step the Next.js recipe misleads you on. /api/fn/* is Pylon's own function namespace, so the relay is a function in functions/, not a route.ts — Pylon serves it at /api/fn/rtIngest with no routing config. It must be an action: only actions get ctx.request, and without the visitor's user-agent and IP headers every event lands in one country on one device type. Forward the raw body rather than args so a field a future tracker adds rides through untouched, and return the upstream JSON verbatim — the script reads visitorId off that response.
functions/rtIngest.tsimport { action, v } from "@pylonsync/functions"; export default action({ auth: "public", // anonymous visitors; there is no session to require args: { site: v.string(), path: v.optional(v.string()), name: v.optional(v.string()), hostname: v.optional(v.string()), referrer: v.optional(v.string()), visitorId: v.optional(v.string()), visitorHash: v.optional(v.string()), utmSource: v.optional(v.string()), utmMedium: v.optional(v.string()), utmCampaign: v.optional(v.string()), utmContent: v.optional(v.string()), clientType: v.optional(v.string()), }, async handler(ctx, args) { const incoming = ctx.request?.headers ?? {}; const headers: Record<string, string> = { "Content-Type": "application/json" }; for (const h of ["user-agent", "x-forwarded-for", "cf-ipcountry"]) { if (incoming[h]) headers[h] = incoming[h]; } try { const res = await fetch("https://userevtrail.com/api/fn/ingestEvent", { method: "POST", headers, body: ctx.request?.rawBody?.trim() || JSON.stringify(args), }); return JSON.parse(await res.text()); } catch { // A dropped beacon is a lost datapoint, never a failed page. return { ok: false }; } }, });Load it from the layout
app/layout.tsx is the shell every route renders inside, so the tag belongs there. data-endpoint points the beacon at your relay; it must contain /api/fn/ or the script treats the value as an alternate origin instead of a path. Name the function ingestEvent instead and you can drop the attribute — the script derives its endpoint from its own script origin.
app/layout.tsx<script defer src="/rt/track.js" data-site="SITE_KEY" data-endpoint="/api/fn/rtIngest" />Confirm it's receiving
Open your site in a normal browser tab, then check Realtime in the Revtrail dashboard — your own visit should appear within a few seconds. If nothing arrives, the usual causes are a mistyped site key, an ad blocker on your own browser, or a Content-Security-Policy that blocks the script origin.
Instrument the conversion that matters
revtrail() is a browser global and Pylon renders your pages on the server, so calling it during a render crashes the request. Call it from an event handler or an effect, guarded, and register the event as a goal in the dashboard. Pageviews tell you traffic; this tells you whether the traffic worked.
in a client event handlerawait db.fn("createAccount", { email }); window.revtrail?.("signup");Wire revenue in from Stripe
Point a Stripe webhook at Revtrail for checkout.session.completed and invoice.payment_succeeded, save the signing secret in your site settings, and pass the visitor id as the Checkout session's client_reference_id. Payments then attach to the visitor's first-touch channel, including renewals months later. Revenue is never accepted from the browser, so this webhook is the only way money reaches your dashboard.
when you create the Checkout sessionconst visitorId = await revtrail.visitorIdAsync(); // best-effort: omit when null, never block checkout on analytics stripe.checkout.sessions.create({ client_reference_id: visitorId ?? undefined, // …line items, success_url, etc. });
Why the relay can't be a route.ts
Pylon's file-based routes cover the SSR surface, and /api/* is reserved by the runtime before that matching happens — the API router, auth, entities, and every server function live there. A route.ts under app/api/ is discovered by the manifest walk and then never receives a request, which is a particularly slow bug to find because nothing errors. A function in functions/ is the same amount of code and it is the path the framework actually serves.
Conversions fire from three different places
A React handler calls window.revtrail?.() directly. A no-JS <Form> posting to a route.ts completes entirely on the server, so there is no client code left to run — POST the event to your relay from the handler with the visitor id you carried through the form. And a conversion that happens in a cron or a webhook (a trial converting, a subscription renewing) belongs to the payment webhook, not to a browser event at all.
Multi-tenant apps: whose visitors are these?
Plenty of Pylon apps serve their own marketing site and their customers' pages from one binary — /pricing and /:orgSlug/:eventSlug in the same route table. Render the snippet from a component you place on your own surfaces rather than in the root layout. In the root layout it also loads on customer-facing tenant pages, and you end up measuring their visitors, which is both noise in your funnel and a conversation you don't want to have with a customer.
Questions people actually ask
- Can I just use the third-party script tag in a Pylon app?
- Not reliably. Beacons post to our ingest endpoint, and a cross-origin POST from your domain is refused by its CORS preflight, so the script loads and no events arrive. The relay above is what makes the install work; it is also what survives ad blockers.
- Do I need the data-endpoint attribute?
- Only if your relay function isn't named ingestEvent. The script derives <script origin>/api/fn/ingestEvent by default, which already points at your app. If you do set it, the value must contain /api/fn/ — anything else is read as an alternate origin.
- Where does the snippet go — app.ts, a page, or the layout?
- app/layout.tsx, or a component the layout renders. app.ts is the data model and manifest; it has no HTML. Putting the tag on individual pages is how three routes end up unreported.
- Do I need a cookie banner for this?
- Not for Revtrail. The default identity is a daily rotating hash — no cookies, no persistent identifier, nothing stored on the device. If you run ad pixels or other analytics that do use cookies, their obligations are unchanged.
- Will this slow my site down?
- The script is deferred, so it never blocks rendering, and it's about 3KB over the wire gzipped. It fires one beacon per pageview.
Install guides