Tracking clicks from within an email sent via Ghost(Pro)

Happy New Year everyone.

One of the primary reasons I am supporting Ghost(Pro) is because of the promise of integrating content across a web CMS and an email newsletter without requiring an external platform. I was happy to see the progress wrapping up 2020 in v3.40.0 that allowed monitoring email opens.

Now that we are in 2022, any idea when will we be able to monitor clicks from within an email?

I would really prefer clicks analyzed by post rather than by user, since I would like to track interactions anonymously. I care more about what is getting attention than who as I find the latter to be too intrusive. I am trying to avoid Google Analytics for the same reason by using Plausible instead. Thanks.

This is on the radar for future feature improvements, but it’s non-trivial to implement and it needs some careful planning :slight_smile:

In the meantime, you can use open rates to gauge email engagement from the member dashboard.

You may have heard that Apple introduced Mail Privacy Protection - which means they no longer report open rates. Some other email clients like also do this.

The outcome is that subscribers will either look like they never open emails, or in many cases, the email provider returns “read” to everything so it looks like they open 100% of emails, when they don’t. More reading here.

One thing you can do in Ghost to get a clearer picture of engagement is to filter these false positives/negatives out:


Thanks Kym! Fingers crossed then. I could not find this feature in the Ideas forum but I would definitely vote for it :slight_smile:

One thing I’ve been doing is using a link shortener for all my newsletter links. It’s a bit of a hassle but it will give you some click data! I’ve been using out of habit but there are tons out there.


@katyenka That is a great solution when you have a couple of links. I considered that for my needs, but my messages have about 20 unique external links each which would quickly become unsustainable.

Good news though… I was able to figure out a way to track these in Plausible. There may be more elegant solutions but mine has worked thus far. Would anyone be interested if I were to document how to do it?

1 Like

How to track clicks through web analytics within an email sent via Ghost(Pro)

I still would like to have this native functionality in Ghost but I hope this helps others for the time being who had the same question I did. I am basing these instructions off of the “edition” theme since that is what I am using. Big hat tip to SitePoint for the URLSearchParams code. As I said, I am sure there are more elegant ways to achieve this but it gets the job done. Please feel free to chime in and simplify or make this more widely applicable.

  1. Optional Turn off the Portal button under Settings > Membership > Portal Settings. This is not required but leaving it on shows the button on a temporary, otherwise blank page which looks weird. Hopefully this setting will be able to be overridden on a page-by-page or template basis in the future.
  2. Ensure your web analytics code is added in Ghost to Settings > Site Injection.
  3. Download your current theme from Ghost and back it up locally.
  4. Copy these theme files into a new folder where you will do your work. In the new folder, you should duplicate the default.hbs file and give the clone a new memorable name that starts with custom such as custom-blank.hbs. Open that file in your text editor of choice and edit it down to the absolute minimum template required to present a blank page while still allowing room for code injection with {{ghost_head}} and {{ghost_foot}}. Would love the ability to add a blank template as a default feature in the future :slight_smile:
  5. Zip up the folder and give it a new name so that you do not overwrite your old template.
  6. Upload the new theme zip file to Ghost.
  7. Create a new page in Ghost and choose the new custom template you created above.
  8. In the new page, add the following snippet to your Page Header via Code Injection.
<meta name="robots" content="noindex">
<script type = "text/javascript">

function getAllUrlParams(url) {

  // get query string from url (optional) or window
  var queryString = url ? url.split('?')[1] :;

  // we'll store the parameters here
  var obj = {};

  // if query string exists
  if (queryString) {

    // stuff after # is not part of query string, so get rid of it
    queryString = queryString.split('#')[0];

    // split our query string into its component parts
    var arr = queryString.split('&');

    for (var i = 0; i < arr.length; i++) {
      // separate the keys and the values
      var a = arr[i].split('=');

      // set parameter name and value (use 'true' if empty)
      var paramName = a[0];
      var paramValue = typeof (a[1]) === 'undefined' ? true : a[1];

      // (optional) keep case consistent
      paramName = paramName.toLowerCase();
      if (typeof paramValue === 'string') paramValue = paramValue.toLowerCase();

      // if the paramName ends with square brackets, e.g. colors[] or colors[2]
      if (paramName.match(/\[(\d+)?\]$/)) {

        // create key if it doesn't exist
        var key = paramName.replace(/\[(\d+)?\]/, '');
        if (!obj[key]) obj[key] = [];

        // if it's an indexed array e.g. colors[2]
        if (paramName.match(/\[\d+\]$/)) {
          // get the index value and add the entry at the appropriate position
          var index = /\[(\d+)\]/.exec(paramName)[1];
          obj[key][index] = paramValue;
        } else {
          // otherwise add the value to the end of the array
      } else {
        // we're dealing with a string
        if (!obj[paramName]) {
          // if it doesn't exist, create property
          obj[paramName] = paramValue;
        } else if (obj[paramName] && typeof obj[paramName] === 'string'){
          // if property does exist and it's a string, convert it to an array
          obj[paramName] = [obj[paramName]];
        } else {
          // otherwise add the property

  return obj;

   var destination = getAllUrlParams().utm_content;
   document.write('<meta http-equiv="refresh" content="1; url=' + destination + '"/>');

Then add utm_content parameters to the links you want to track in your email using your preferred destination. Of course you can add utm_medium and utm_source or others as needed. For instance, if you want to track a link in your email to the Ghost forum, then you could use the following link:

<a href="">Ghost forum</a>

This solution was designed to show your click data within Plausible but should work with Google Analytics as well. I suppose you could incorporate all of the code into the template itself rather than the code injection snippet if you prefer. You also do not have to use utm_content if you have set that up for other data; you could switch the parameter to use utm_term or others. Remember that you will need to encode spaces with %20 if you choose to use them in any of your parameters.

@adam can you share more information about what the URLSearchParams code injection part of your process is doing?

You should be able to add UTM parameters to your links without any of the previous steps, and still see that information in Plausible.

@kym The URLSearchParams code allows you to pull a parameter from the link for use in the meta refresh, in this case utm_content. The meta refresh is what directs the user to the ultimate destination after the blank page loads and increments the visit. Plausible does indeed normally track UTM parameters on its own but cannot do so here without this intermediary step, since the whole point of this process is to measure clicks to external sites.

As I said, there are definitely cleaner ways to do this but it works.

Ahh, of course - thanks for sharing and clarifying.

Sure! I am a marketer, not a developer so doing what I can to support the community :slight_smile: