sdxc

Type to search, or start from one of these:

[ Data & background work ]

Know where visitors come from

Record a visitor's first and last touch from UTM parameters, click IDs and referrers, then store it with the account, the checkout or the newsletter reader they become.

Last updated 2026-10-08

A visitor clicks a link in a newsletter, reads three pages, leaves, comes back a week later from a search and signs up. This guide records both arrivals in a signed cookie as they happen, then reads them on the request where the visitor becomes something you keep: an account, a paying customer, a newsletter reader. The campaign is stored with that record, so a report on signups by campaign is a query over your own table.

@sdxc/attribution reads the touch off a request, keeps the first and last touch per visitor and flattens them for the places they are stored.

npm add @sdxc/attribution

What a touch holds

A touch is one arrival: when it happened, the page the visitor landed on and what the request said about how they got there. readTouch reads one off a URL and the Referer header:

import { readTouch } from "@sdxc/attribution";

let touch = readTouch(
	new URL(
		"https://example.com/pricing?utm_source=Newsletter&utm_campaign=Launch%20Week",
	),
	{ referrer: "https://mail.google.com/" },
);
// touch.landingPath === "/pricing"
// touch.utm => { source: "newsletter", campaign: "launch-week" }
// touch.referrer => { host: "mail.google.com", kind: "email" }
// touch.channel === "email"

Every stored value is a slug: trimmed, lowercased, whitespace turned into - and cut at 64 characters, so Newsletter and newsletter group together in a report. A value holding @ or :// is an address or URL a merge tag put there, and it is dropped. The referrer keeps only its hostname, and a referrer on the page's own host is internal navigation. A click identifier such as gclid or fbclid is kept as the network's name and whether the click was paid. Parameters that identify an email subscriber, such as mc_eid, are never stored; they credit the touch to email.

channel is the acquisition channel the touch adds up to: paid-search, paid-social, display, email, affiliate, organic-social, organic-search, referral, other or direct. The API reference lists the rules in the order they are tried.

Keep the first and last touch

The middleware reads the touch on every page view and keeps two: the first within a window, and the latest one that was not direct. It stores them in a cookie. Sign the cookie, so a visitor cannot credit themselves to a campaign, and give it the same lifetime as the window:

app/lib/cookies.ts
import type { Cookie } from "remix/cookie";

import { env } from "cloudflare:workers";
import { createCookie } from "remix/cookie";

const NINETY_DAYS = 60 * 60 * 24 * 90;

export function attributionCookie(): Cookie {
	return createCookie("attribution", {
		path: "/",
		maxAge: NINETY_DAYS,
		httpOnly: true,
		sameSite: "Lax",
		secure: true,
		secrets: [env.COOKIE_SECRET],
	});
}

COOKIE_SECRET is a secret declared in your Worker's configuration. Add the middleware to the global chain, ahead of anything that ends a request early:

bootstrap/app.tsx
import type { Middleware } from "remix/router";

import { attribution } from "@sdxc/attribution/middleware";
import { log } from "@sdxc/logger/middleware";
import { formData } from "remix/middleware/form-data";
import { createRouter } from "remix/router";

import { attributionCookie } from "~/app/lib/cookies";

import { logger } from "./logger";

export default function application() {
	let middleware: Middleware[] = [
		log(logger) as Middleware,
		attribution({ store: attributionCookie() }),
		formData() as Middleware,
		// …then cop() and a renderer
	];

	let router = createRouter({ middleware });
	// router.map(…) for each route
	return router;
}

Every request gets ctx.attribution with three fields: current, this request's own touch, and the stored first and last. Writes happen only on a page navigation: a GET the browser marks as loading a document. A form post, a fetch, a bot and a direct revisit write nothing, so a form action reads the touches the visitor's page views recorded. The first touch is replaced when the stored one is older than window (90 days by default), so a visitor returning a year later from a new campaign is credited to it. A response that sets the cookie is marked Cache-Control: private, and a cookie that fails to verify or validate reads as no record.

An app that already runs remix/session's middleware passes store: "session" and installs attribution() after it; the record then lives under the session's attribution key and the session middleware commits it.

The remaining options:

  • consent decides per request whether the visitor allows storage. It defaults to honoring Global Privacy Control (Sec-GPC: 1). When it answers false, current is still published, the stored record is removed and nothing is written.

  • redirect: true answers a page view that carries tracking parameters with a 302 to the same URL without them, once the touch is stored.

  • clickIds: "keep" stores the click identifier's value too, for an app that uploads offline conversions to an ad network.

  • aliases and referrers add parameters read as a UTM field and host → kind entries to the referrer table; ref is already read as utm_source.

An app serving visitors under the ePrivacy Directive reads its own consent cookie:

attribution({
	store: attributionCookie(),
	consent: (ctx) => {
		let cookie = ctx.request.headers.get("cookie") ?? "";
		return /(?:^|;\s*)consent=analytics/.test(cookie);
	},
});

Store it with the account

The sign-up action is where a visitor becomes a record. Read the touches there and write the fields your reports group by into the account's own columns:

app/data/acquisition.ts
import type { Touch } from "@sdxc/attribution";

export interface Acquisition {
	channel: string;
	source: string | null;
	campaign: string | null;
	referrer: string | null;
	landing_path: string | null;
}

export function acquisitionOf(touch: Touch | null): Acquisition {
	return {
		channel: touch?.channel ?? "direct",
		source: touch?.utm?.source ?? null,
		campaign: touch?.utm?.campaign ?? null,
		referrer: touch?.referrer?.host ?? null,
		landing_path: touch?.landingPath ?? null,
	};
}

A null touch is a visitor who arrived with nothing to credit, or one who withheld consent, and it reads as direct. The action passes the first touch, which answers "what found us":

app/http/controllers/sign-up.ts
import { toMetadata } from "@sdxc/attribution";
import { createAction } from "remix/router";

import { acquisitionOf } from "~/app/data/acquisition";
import { createAccount } from "~/app/data/accounts";
import routes from "~/routes/web";

export default createAction(routes.signUp.action, async (ctx) => {
	// …validate the form into `email`
	let account = await createAccount(ctx.db, {
		email,
		...acquisitionOf(ctx.attribution.first),
		attribution: toMetadata(ctx.attribution),
	});
	// …start their session and redirect
});

createAccount is your own model function. The spread columns are the ones a report filters on. toMetadata keeps both touches as one flat record, with keys such as first_channel, first_source, last_campaign and last_landing, only for the fields a touch carries, which fits a JSON column for the questions you have not asked yet.

Pass it to the checkout

A sale usually happens on the billing platform's page, and the platform reports it to you later in a webhook. Attach the touches to the checkout, and the platform hands them back on the order:

app/http/controllers/billing/checkout.ts
import { toMetadata } from "@sdxc/attribution";

let checkout = await ctx.billing.checkouts.create({
	product: "pro",
	customer: { externalId: ctx.account.id },
	metadata: toMetadata(ctx.attribution),
});

Two full touches come to at most 28 keys of under 300 characters each, inside the metadata limits of Polar and Stripe. Charge for your app covers the rest of the checkout and the webhook that receives the order.

Credit a newsletter reader

A newsletter provider stores campaign fields beside a reader. toCampaign flattens one touch into them, with the landing path resolved into an absolute URL against the request's:

import { toCampaign } from "@sdxc/attribution";

let outcome = await ctx.newsletter.subscribers.subscribe({
	email: email.data,
	attribution: toCampaign(ctx.attribution.last ?? ctx.attribution.first, ctx.url),
});

The last touch answers "what brought them back to sign up", falling back to the first when every later visit was direct. toUtmParams(touch) gives the same campaign under utm_* names, for a provider that takes those. Run a newsletter list builds the whole form.

@sdxc/attribution/parameters knows every parameter the touch reader does. withoutTracking copies a URL without them, keeping every other parameter in the order and encoding its author wrote, which suits a canonical link or a link a visitor copies to share:

import { withoutTracking } from "@sdxc/attribution/parameters";

withoutTracking(new URL("https://example.com/post?p=123&utm_source=x&fbclid=abc"));
// https://example.com/post?p=123

isTrackingParameter(name) answers for one name, and CAMPAIGN_PARAMETERS and CLICK_IDENTIFIERS list them with the network each identifier belongs to.

Test it

The middleware needs no app around it, so a test builds a router with only it, lands on a page the way a browser does, keeps the cookie the response set, and sends it with the request that converts:

app/data/acquisition.test.ts
import { attribution } from "@sdxc/attribution/middleware";
import { createCookie } from "remix/cookie";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";

import { acquisitionOf } from "~/app/data/acquisition";

function createTestRouter() {
	let cookie = createCookie("attribution", { secrets: ["test-secret"] });
	let router = createRouter({ middleware: [attribution({ store: cookie })] });
	router.get("/pricing", () => new Response("Pricing"));
	router.post("/sign-up", (ctx) =>
		Response.json(acquisitionOf(ctx.attribution.first)),
	);
	return router;
}

test("credits a sign-up to the campaign it first landed from", async () => {
	let router = createTestRouter();
	let landing = await router.fetch(
		new Request(
			"https://example.com/pricing?utm_source=Mastodon&utm_campaign=Launch",
			{
				headers: { "sec-fetch-dest": "document" },
			},
		),
	);
	let cookie = landing.headers.get("set-cookie")?.split(";")[0] ?? "";

	let signUp = await router.fetch(
		new Request("https://example.com/sign-up", {
			method: "POST",
			headers: { cookie },
		}),
	);

	expect(await signUp.json()).toMatchObject({
		channel: "other",
		source: "mastodon",
		campaign: "launch",
		landing_path: "/pricing",
	});
});

Without Sec-Fetch-Dest: document, or an Accept naming text/html, the landing request is not a navigation and writes no cookie, which is also how a test checks that a fetch leaves the record alone. A link with a source and no medium credits the other channel, as the expectation shows; add utm_medium to links you control so they land in the channel you mean.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.