An absolutely dirt-simple, 15-line UTM tag forwarder

Updated 2021-05-06: A reader reminds me that per my own notes on the Nation (!) the latest Forms 2.0 library now caches the current URL as soon as forms2.min.js is loaded (instead of reading it when loadForm() runs). I’ve modified the code and installation instructions accordingly.

I heartily recommend comprehensive attribution solutions like ConversionPath.[1] With such solutions (which may be open-source, freemium, or totally commercial) you have a record of all historical touches based on UTMs and referrers, then pair them with Marketo flows, programs and reports to give you full cycle visibility.

But sometimes you need a lot less than that. Your requirement may simply be “Remember the UTM tags that were on a person's entry page, and add them to forms they fill out after tooling around your site.”

That's a really simple goal. Yet the code people write for it is frequently overengineered and clunky.

See, there are abiding misconceptions about what it takes to remember a set of UTM tags, if that's truly all you need to do.

Misconception #1: You must disassemble the query string

This is where people start to go wrong: thinking they must store the original query string (utm_medium=something&utm_term=My%20Favorite%20Things) as separate name-value pairs.

OK, there's nothing inherently wrong with breaking down the query string into param names and corresponding values. If you do it losslessly (meaning you can restore exactly what they originally were) no harm, no foul. And if you use an established parser library like URI.js you're pretty well assured that you'll catch all the edge cases.

The problem is that people don't use established parsers. They try to write their own parsers, thinking it's as simple as looping over a <String>.split(/<RegExp>/) and somehow refusing to believe they won't need 2000 lines of code to get it right.

I've gone down this road before, with custom 10-20 line parsing routines that seem to work in idealized situations. But trust me, the day you decide to trust the guy who's been maintaining his specialized code since 2011 over yourself is a good day.

Or, even better — as you'll see below — the day you realize you don't need to parse URLs at all. Marketo Forms (of course) have a URL parser built in, and if it can be used, you need not fatten up your pages with an external library nor write your own.

Misconception #2: You must use cookies to store data

This is basically like #1. Once you assume you need to store original UTMs in cookies, you have a choice: use a time-tested library or try to roll your own.

For some reason, people think they should roll their own. Yes, parsing document.cookie is easier overall than parsing (For example, in cookie, characters like ; are expressly prohibited within names and values and don't have a standard escaping mechanism. )

But cookie parsing still has decision points (Is a cookie name with no value present or not present? Is it Boolean false, or is it null, or is it empty? Should JSON objects be unmarshaled automatically?) and is best either left to someone more specialized… or, as we shall see, not done at all.

Here I begin to signal where I'm going with my quick-'n'-dirty approach. If you must track UTM tags across all subdomains of a parent domain, yes, you have to use cookies for that. But if you're trying to track someone from when they view

through when they fill out a form on

then you don't need cookies. You can instead turn to the browser's built-in LocalStorage, which is far easier to use than cookies (you don't need to include or build a parser). Its principal restriction is that it only works on and won't share data outside of that exact origin, so not with[2]

Misconception #3: Your code needs to know all the possible utm_<whatever> params

This naturally connects to #1. I see people writing repetitive code like this:

if (query.contains('utm_medium')) cookies.set('utm_medium',query.get('utm_medium'));
if (query.contains('utm_campaign')) cookies.set('utm_medium',query.get('utm_medium'));
if (query.contains('utm_source')) cookies.set('utm_source',query.get('utm_source'));
// etc.

But that's pretty silly. If you really just want all the query params that start with the magic prefix utm_ then loop over the cookie names and store all the ones that match the pattern. It's impossible to go wrong that way, since you still need hidden fields on the form mapped to extant Marketo fields in order to store the data. Accidentally storing utm_my_special_value_oops for later doesn't hurt you.

Misconception #4: You need to have multitouch-aware code

If simultaneous MT tracking was a formal requirement then you absolutely do. But that's exactly when you should turn to a sophisticated, well-tested 3rd-party solution. Handling overlapping attribution tracks requires increasingly sophisticated code, especially as you explore multi-device tracking.

Is that truly what you need right now, though? In my experience, people exploring persistent touch tracking with Marketo Forms (which doesn't happen at all out-of-the-box) want “the UTM tags” — meaning the tags on a single entry page, not parallel sets of UTM tags stored at once.

Even if multiple stored touches (from different devices) end up contributing to a back end MT scheme, that doesn't necessarily mean the browser has to track more than one set of tags at a time. You may want to start small by just restoring a recent interesting touch whenever somebody fills out a form. Or, to phrase it the way people on Community often do, “If somebody navs away from the advertised page before filling out a form, I still want to know what brought them in.” That's what the code below can do.

The slimmed-down concept

  1. If you only want the most recent touch stored for later, you don't have to manage separate tracks and rules and only need one block of saved data.
  2. If you only track people within a single site (likely your corporate website), then you don't need cookies and can use the simpler LocalStorage to save data from page-to-page.
  3. If you save the initial query string as-is (instead of breaking it up) you can restore it as-was and have the Forms 2.0 URL parser handle it.
  4. I've been leaving out the last factor, which is… I thought it would be cool to try for the lightest, tightest, but still comprehensible code for this narrow need.

I'm calling this a UTM tag “forwarder.” It'll remember the last UTM tags a person followed, even if they've since navigated to UTM-free URLs. You can then capture those tags in a Marketo form anywhere on the same site.

Add this JS to all your pages.

  • If the page has a Marketo form then make sure this code runs above the <script> that loads the forms2.min.js library
  • If the page doesn't have a Marketo form, then you can put the JS anywhere.
  var current = {
        state : document.location.href,
        query :,
        time : new Date().getTime()
      storage = {
        area : window.localStorage,
        key : 'last_utm_query'
      updated = {};

  if (/(^|&)utm_/.test(current.query)) {
    storage.area[storage.key] = JSON.stringify({ time:current.time, query:current.query });
  } else if (restored = JSON.parse(storage.area[storage.key] || '""')) {    
    if ( current.time - restored.time <= opts.expireDays * 864E5 ) {
      updated.state = document.createElement('a');
      updated.state.href = current.state; += ( ? '&' : '') + restored.query + '&restored=' + restored.time;
      history.replaceState('', {}, updated.state);
  expireDays : 30

How do I set up hidden fields?

Set them to Auto-Fill from query params!

Here's how the code works: if the current URL doesn't have any utm_ params, then the code modifies the URL by appending the last query string that did have UTMs (if it's available and unexpired).

Then you load the form after the URL has been modified.

So whether or not the UTM tags were originally in the document's query string, as far as the form knows the value is now in a query param.

Are there options?

Yes, expireDays is the max number of days that saved query strings will be usable

Anything else?

Yes, if a query string was saved + restored, the code also appends the query param restored with the timestamp of the saved data, so you can differentiate the two cases.

Browser support

The code isn't compatible with IE 8 or 9, FWIW (no replaceState there).


[1] Disclosure: ConversionPath uses some of my original code, though I have no revenue relationship with the company.

[2] Nor will it let you read data from (the same website accessed over http: instead of https:) though you're vanishingly unlikely to have a problem with this.