UTM Link Builder — TypeScript source
Build campaign tracking URLs with utm_source, utm_medium and utm_campaign parameters. Bulk mode processes a whole list, presets and import round-trip existing tracking URLs, and a validator flags attribution-breaking values — runs entirely in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure UTM link logic — no React, no DOM. Deterministic and side-effect free.
// Invalid base URLs return null instead of throwing; lint reports GA4 naming
// conventions rather than hard failures.
export interface UtmParams {
source?: string;
medium?: string;
campaign?: string;
term?: string;
content?: string;
}
export interface UtmLintFinding {
param: string;
level: 'warn' | 'error';
code: 'convention' | 'missing' | 'unknown' | 'invalid-url';
message: string;
}
/** Canonical utm_* parameter order used on every build. */
const CANONICAL: Array<[keyof UtmParams, string]> = [
['source', 'utm_source'],
['medium', 'utm_medium'],
['campaign', 'utm_campaign'],
['term', 'utm_term'],
['content', 'utm_content'],
];
/** utm_* keys we know about; anything else in a linted URL is flagged. */
const KNOWN_UTM_KEYS = new Set([
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'utm_id',
]);
/** Parse a base URL, coercing a scheme-less host into https. null when hopeless. */
function parseUrl(base: string): URL | null {
try {
return new URL(base);
} catch {
try {
return new URL('https://' + base);
} catch {
return null;
}
}
}
/**
* Build a campaign URL: strip any stale utm_* params from the base, keep every
* unrelated query param in place, then append the given params in canonical
* order. Empty-string params are omitted. Returns null for an unparseable base.
*/
export function build(baseUrl: string, params: UtmParams): string | null {
const url = parseUrl(baseUrl);
if (!url) return null;
// Collect first, delete after — mutating searchParams during iteration skips entries.
const stale: string[] = [];
for (const key of url.searchParams.keys()) {
if (key.startsWith('utm_')) stale.push(key);
}
for (const key of stale) url.searchParams.delete(key);
for (const [field, queryKey] of CANONICAL) {
const value = params[field];
if (value !== undefined && value !== '') url.searchParams.set(queryKey, value);
}
return url.toString();
}
/**
* Report GA4 naming-convention issues on a URL: utm_campaign with spaces or
* uppercase, missing utm_source/utm_medium, and unknown utm_* params.
*/
export function lint(url: string): UtmLintFinding[] {
const parsed = parseUrl(url);
if (!parsed) {
return [
{
param: '',
level: 'error',
code: 'invalid-url',
message: 'This does not parse as a URL.',
},
];
}
const findings: UtmLintFinding[] = [];
const entries = [...parsed.searchParams.entries()];
for (const [key, value] of entries) {
if (!key.startsWith('utm_')) continue;
if (!KNOWN_UTM_KEYS.has(key)) {
findings.push({
param: key,
level: 'warn',
code: 'unknown',
message: `Unknown tracking parameter ${key}.`,
});
}
}
const campaign = parsed.searchParams.get('utm_campaign');
if (campaign !== null && (campaign !== campaign.toLowerCase() || campaign.includes(' '))) {
findings.push({
param: 'utm_campaign',
level: 'warn',
code: 'convention',
message: 'GA4 convention is lowercase with hyphens, no spaces.',
});
}
for (const key of ['utm_source', 'utm_medium']) {
if (parsed.searchParams.get(key) === null) {
findings.push({
param: key,
level: 'warn',
code: 'missing',
message: `${key} is recommended for GA4 attribution.`,
});
}
}
return findings;
}
export interface BulkLine {
input: string;
output: string | null;
}
/** Apply `build` to each non-blank line; blank lines are skipped. */
export function bulk(text: string, params: UtmParams): BulkLine[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
.map((line) => ({ input: line, output: build(line, params) }));
}
/** Full editor state: the destination plus the five standard params. */
export interface UtmConfig {
baseUrl: string;
params: UtmParams;
}
/** Result of the import roundtrip: the base with utm_* stripped + the params. */
export interface ParsedUtmUrl {
baseUrl: string;
params: UtmParams;
}
export type UtmIssueCode =
| 'missing-param'
| 'value-space'
| 'value-non-ascii'
| 'value-delimiter'
| 'base-not-absolute';
export interface UtmIssue {
code: UtmIssueCode;
severity: 'warn' | 'info';
value?: string;
}
/**
* Import roundtrip: split a finished tracking URL back into editor state.
* Every utm_* param is stripped from the base (unknown keys included) and the
* five standard fields are recovered, so `build(baseUrl, params)` reproduces
* the original link. Returns null when the text is not a URL.
*/
export function parseUtmUrl(url: string): ParsedUtmUrl | null {
const parsed = parseUrl(url ?? '');
if (!parsed) return null;
const fieldByQueryKey = new Map<string, keyof UtmParams>(
CANONICAL.map(([field, queryKey]) => [queryKey, field]),
);
const params: UtmParams = {};
for (const [key, value] of [...parsed.searchParams.entries()]) {
const field = fieldByQueryKey.get(key);
if (field && value !== '') params[field] = value;
}
const stale: string[] = [];
for (const key of parsed.searchParams.keys()) {
if (key.startsWith('utm_')) stale.push(key);
}
for (const key of stale) parsed.searchParams.delete(key);
return { baseUrl: parsed.toString(), params };
}
/**
* Builder-family validation. All checks are warns: missing utm_source /
* utm_medium, spaces or non-ASCII inside param values, a literal ? or # in a
* value (breaks the query string), and a base that is not absolute http(s).
*/
export function validateUtm(config: UtmConfig): UtmIssue[] {
const issues: UtmIssue[] = [];
const params = config.params ?? {};
for (const [field] of CANONICAL) {
if (field !== 'source' && field !== 'medium') continue;
if (!params[field]) {
issues.push({ code: 'missing-param', severity: 'warn', value: `utm_${field}` });
}
}
for (const [field, queryKey] of CANONICAL) {
const value = params[field];
if (!value) continue;
if (value.includes(' ')) {
issues.push({ code: 'value-space', severity: 'warn', value: queryKey });
}
if (/[^\x00-\x7F]/.test(value)) {
issues.push({ code: 'value-non-ascii', severity: 'warn', value: queryKey });
}
if (/[?#]/.test(value)) {
issues.push({ code: 'value-delimiter', severity: 'warn', value: queryKey });
}
}
const base = (config.baseUrl ?? '').trim();
if (base !== '' && !/^https?:\/\//i.test(base)) {
issues.push({ code: 'base-not-absolute', severity: 'warn', value: base });
}
return issues;
}
// URL codecs for shareable state. Each component (base URL, param value) is
// encodeURIComponent'd before it lands in the params, so query-string
// delimiters inside a value can never confuse the roundtrip. Keys: u = base,
// s/m/c/t/k = source/medium/campaign/term/content; absent keys are omitted.
const enc = (s: string) => encodeURIComponent(s);
const dec = (s: string): string => {
try {
return decodeURIComponent(s);
} catch {
return s; // malformed escape - keep verbatim rather than throw
}
};
const QUERY_KEYS: Record<keyof UtmParams, string> = {
source: 's',
medium: 'm',
campaign: 'c',
term: 't',
content: 'k',
};
export function toQuery(config: UtmConfig): string {
const p = new URLSearchParams();
const base = (config.baseUrl ?? '').trim();
if (base !== '') p.set('u', enc(base));
for (const [field, queryKey] of Object.entries(QUERY_KEYS) as Array<[keyof UtmParams, string]>) {
const value = config.params?.[field];
if (value !== undefined && value !== '') p.set(queryKey, enc(value));
}
return p.toString();
}
export function fromQuery(params: URLSearchParams): UtmConfig | null {
const u = params.get('u');
const hasParams = Object.values(QUERY_KEYS).some((k) => params.get(k) !== null);
if (u === null && !hasParams) return null;
const out: UtmParams = {};
for (const [field, queryKey] of Object.entries(QUERY_KEYS) as Array<[keyof UtmParams, string]>) {
const value = params.get(queryKey);
if (value !== null && value !== '') out[field] = dec(value);
}
return { baseUrl: u !== null ? dec(u) : '', params: out };
}
/** Full-config presets - one click fills the editor with a working campaign. */
export const PRESETS: Record<string, UtmConfig> = {
newsletter: {
baseUrl: 'https://example.com/newsletter',
params: { source: 'newsletter', medium: 'email', campaign: 'july-newsletter' },
},
'twitter-x': {
baseUrl: 'https://example.com/launch',
params: { source: 'twitter', medium: 'social', campaign: 'launch-tweet', content: 'pin-tweet' },
},
'github-readme': {
baseUrl: 'https://example.com/oss',
params: { source: 'github', medium: 'readme', campaign: 'repo-cta', content: 'badge' },
},
};
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →