Skip to content

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 →