Metafields in Shopify without apps

Cover image by Chiara F

Poke about in the documentation for Shopify long enough and you'll end up in metafields. Metafields promise to unlock more than just a single heading and WYSIWTF panel's worth of unique content on each page of your Shopify store.

However, your excitement will quickly be dashed, when you realise that in order to actually add any data into a metafield, you need to build (and host) your own app, or pay a monthly fee for a third-party one.

But what if (bear with me here) you were particularly stubborn, and wanted to dabble in metafields, but not in app development? Boy, are you in the right place, buddy.

Creating metafields

Hidden away in the depths of the Shopify documentation is a trick for using URL querystrings to open up the bulk editor with new metafields available to edit (it's right at the bottom of the page).

It works kinda like this:

An example URL with different parts of it labelled as shop name, content type, namespace, metafield name and data type

So it's possible to edit metadata fields just by hacking away at query strings. But that's a miserable experience for anyone, no matter how much they love a CLI. When I first saw the URL above, I figured I could build a form which (via a get request) could produce the same thing. It's a little more complicated than that, but not that much more.

Here's the data I'd need to capture:

  • What type of content the metafields are being attached to
  • A namespace, so we can group similar metafields together
  • key names, for the data we're capturing
  • What type of data it is

Here's the markup, which you could dump into a Liquid template, then use that to create a page, then hide somewhere on your Shopify site where you're sure your customers won't find it (the legal terms and conditions page, for example):

<form action="/admin/bulk" method="get" id="metaedit">
  <fieldset>
    <legend>Attach metafield to:</legend>
    <p>
        <input type="radio" name="resource_name" value="Article" id="resource_name_article">
        <label for="resource_name_article">An article</label>
    </p>
    <p>
        <input type="radio" name="resource_name" value="Page" id="resource_name_page" checked>
        <label for="resource_name_page">A page</label>
    </p>
    <p>
        <input type="radio" name="resource_name" value="Product" id="resource_name_product">
        <label for="resource_name_product">A product</label>
    </p>
    <p>
        <input type="radio" name="resource_name" value="Collection" id="resource_name_collection">
        <label for="resource_name_collection">A collection</label>
    </p>
    <p>
        <input type="radio" name="resource_name" value="ProductVariant" id="resource_name_ProductVariant">
        <label for="resource_name_ProductVariant">A product variant</label>
    </p>
</fieldset>

<fieldset>
    <legend>Field labels:</legend>
    <p>Leave this blank for <code>global</code></p>
    <p>
        <input type="text" id="namespace">
        <label for="namespace">Namespace for your new metafield</label>
    </p>
    <fieldset id="new-labels">
        <legend>New metafield</legend>
        <p>
            <input type="text" id="newmeta1" pattern="[A-Za-z_-]+" data-js="new-field">
            <label for="newmeta1">Name your new metafield</label>
            <select id="type1">
                <option value="string" selected>String</option>
                <option value="money">Money</option>
                <option value="boolean">Boolean</option>
                {% comment %}
                While you can create select boxes in the bulk editor,
                they don't have any options, so I guess there's no point.
                <option value="select">Select</option>
                {% endcomment %}
                <option value="number">Number</option>
            </select>
            <label for="type">Data type</label>
        </p>
    </fieldset>
    <p>
      <input type="hidden" name="edit" id="hiddenEdit">
      <button type="button" id="add-field">+ Add field</button></p>
      <input type="submit" value="Bulk edit metafields">
    </p>
  </fieldset>
</form>

This'll also need some JavaScript, to concatenate together the different strings into the URL to generate new fields:

// Create new metafields
var addFieldTrigger = document.getElementById('add-field');
// Adds a new field into the form
addFieldTrigger.addEventListener('click', function() {
    var newLabels = document.getElementById('new-labels');
    if (newLabels) {
        var count = document.querySelectorAll('[data-js="new-field"]').length + 1;
        // Additional metafield inputs markup
        var fieldMarkup = '<input type="text" id="newmeta'+count+'" pattern="[A-Za-z_-]+" data-js="new-field"><label for="newmeta'+count+'">Name your new metafield</label> <select id="type'+count+'"><option value="string" selected>String</option><option value="money">Money</option><option value="boolean">Boolean</option><option value="number">Number</option></select><label for="type'+count+'">Data type</label>';
        var newField = document.createElement('p');
        newField.innerHTML = fieldMarkup;
        newLabels.appendChild(newField);
    }
});

var theForm = document.getElementById('metaedit');
theForm.onsubmit = buildQuery;
function buildQuery() {
    var hiddenEdit = document.getElementById('hiddenEdit');
    var newSpace =   document.getElementById('namespace').value;
    if (newSpace === '') newSpace = 'global';
    var newFields =  document.querySelectorAll('[data-js="new-field"]');
    var editString = '';
    var metaType;
    // Loop through the input elements
    for (var i = 0; i < newFields.length; i++) {
        // Don't include any placeholder values
        if (newFields[i].value !== '') {
            metaType = document.getElementById('type' + (i + 1)).value;
            // Build up the URL
            if (i !== 0) editString = editString + ',';
            editString = editString + 'metafields.' + newSpace + '.' + newFields[i].value + ':' + metaType;
        }
    }
    hiddenEdit.value = editString;
}

Completing this form will send you to Shopify's bulk editor, but rather than editing the built-in fields which are part of products and pages by default, it allows you to edit any number of arbitrary fields of your own devising.

Structuring your data

Let's say you had a series of frequently asked questions about a particular product. You could structure your data like this:

  • faq (namespace)
    • q-one Is this product suitable for vegans?
    • a-one Yes. This is a mug, but is not made from bone china.
    • q-two Is this product suitable for children?
    • a-two No. The slogan on the mug is No. 1 Dad and this is not a responsibility children are qualified to undertake.

Hold on a moment, you're probably thinking, why not use numbers instead of one and two? While it's possible to get to the bulk editing screen using numbers, any data you enter against such a field will not be saved. That's why some of the inputs have that sneaky pattern attribute, to stop you from adding them.

What if I need to edit form fields in the future?

If you attempt to add a new metafield which already has data against it, you'll simply see that data in place, once you reach the bulk editor.

The URL to batch edit the same set of meta-data will remain the same. Rather than filling out the form each time, why not include it on the site, as a link? This isn't quite as daft as it sounds, as you can hide this link for all users except those who are logged into the admin interface of Shopify (they're also the only group of users who will also be able to edit the content).

Adding in content just for Shopify admin users isn't out-of-the-box Shopify functionality, so there may come a time when this method stops working, but for now, this is how to do it.

In layout/theme.liquid is the following snippet of code:

{{ content_for_header }}

This generates a bunch of meta-tags and JavaScript intended to sit within the head of your site controlling various aspects of Shopify's functionality. By turning this into a string and looking for patterns within it, we can work out if the current user is an admin or not.

Luckily, {{ content_for_header }} is global which means we're not just limited to using it in theme.liquid.

Let's say you have an FAQ section on your product pages which is populated with content using metadata. You could include the link to edit this content like this:

{% capture CFH %}{{ content_for_header  }}{% endcapture %}

{% if CFH contains 'admin_bar_iframe' %}
  {% assign admin = true %}
{% elsif CFH contains 'preview_bar_injector-' %}
  {% assign admin = true %}
{% endif %}

{% if admin %}
  <p>
    <a href="/admin/bulk?resource_name=Product&edit=metafields.faq.q-one%3Astring%2Cmetafields.faq.a-one%3Astring%2Cmetafields.faq.q-two%3Astring%2Cmetafields.faq.a-two%3Astring%2Cmetafields.faq.q-three%3Astring%2Cmetafields.faq.a-three%3Astring%2Cmetafields.faq.q-four%3Astring%2Cmetafields.faq.a-four%3Astring">
      Edit metadata
    </a>
  </p>
{% endif %}

That big, ugly URL is what is generated by the form we made earlier. Because this markup only appears in product.liquid, the URL is specific to that set of meta-data. If you wanted to add an edit link for another set of meta-data, you could generate one using the form you created above.

How can I see the data which is saved against content elements?

I'm so glad you asked. The Shopify documentation points at URLs which will show you the JSON for individual elements. But that would be more URL hacking, so let's create another form:

<fieldset>
  <legend>Select a content block</legend>

  <p>
      <select id="pages" data-js="auto">
          <option value="">Select a page</option>
          {% for linklist in linklists %}
              <optgroup label="{{ linklist.title }}">
                  {% for link in linklist.links %}
                      {% unless link.object.id == blank %}
                          <option value="{{ link.object.id }}">{{ link.object.title }}</option>
                      {% endunless %}
                  {% endfor %}
              </optgroup>
          {% endfor %}
      </select>
      <label for="pages">Select a page</label>
  </p>

  <p>
      <select id="products" data-js="auto">
          <option value="">Select a product</option>
          {% for product in collections.all.products %}
              {% unless product.id == blank %}
                  <option value="{{ product.id }}">{{ product.title }}</option>
              {% endunless %}
          {% endfor %}
      </select>
      <label for="products">Select a product</label>
  </p>

  <p>
      <select id="articles" data-js="auto">
          <option value="">Select an article</option>
          {% comment %}
          Add in the names of all of your blogs here,
          for example "news,updates,how-to"
          {% endcomment %}
          {% assign all_blogs = "news" | split: "," %}
          {% for blog_handle in all_blogs %}
              {% assign current_blog = blogs[blog_handle] %}
              <optgroup label="{{ current_blog.title }}">
              {% for article in current_blog.articles %}
                  <option value="{{ article.id }}" data-parentid="{{ current_blog.id }}">{{ article.title }}</option>
              {% endfor %}
              </optgroup>
          {% endfor %}
      </select>
      <label for="articles">Select an article</label>
  </p>

  <p>
      <select id="collections" data-js="auto">
          <option value="">Select a collection</option>
          {%- for collection in collections -%}
          <option value="{{ collection.id }}">{{ collection.title }}</option>
          {%- endfor -%}
      </select>
      <label for="articles">Select a collection</label>
  </p>

  <p>
      <select id="variants" data-js="auto">
          <option value="">Select a product variant</option>
          {% for product in collections.all.products %}
              {% if product.variants.size > 1 %}
                  <optgroup label="{{product.title}}">
                  {% for variant in product.variants %}
                      <option value="{{ variant.id }}" data-parentid="{{ product.id }}">{{ variant.title }}</option>
                  {% endfor %}
                  </optgroup>
              {% endif %}
          {% endfor %}
      </select>
      <label for="product_variant">Select a product variant</label>
  </p>

  <p>
      <button type="button" id="view-json">View <abbr>JSON</abbr></button>
  </p>
</fieldset>

This form will create five select boxes which contain a list of (respectively) your pages (specifically, all the pages which appear in linklists), your products, your blog articles, your collections and your product variants.

It needs a tiny bit of configuration, around this line:

{% assign all_blogs = "news" | split: "," %}

This should be adjusted to be a comma-delimited list of all of the blogs in your site (if you have more than one). For example:

{% assign all_blogs = "news,how-to" | split: "," %}

Here's some JavaScript, which does the redirecting:

// Show content JSON, select boxes
var btnView = document.getElementById('view-json');
btnView.addEventListener('click', function() {
    // The five select boxes, letting you choose the content
    var id, selectBoxes = document.querySelectorAll('[data-js="auto"]');
    for (var i = 0; i < selectBoxes.length; i++) {
        // Content item ID
        id = selectBoxes[i].value;
        // Used for blog articles and product variations
        parentID = selectBoxes[i].options[selectBoxes[i].selectedIndex].getAttribute('data-parentid');
        // This will follow the first valid link found, then stop.
        if (parentID && id !== '') {
            bounce(id,selectBoxes[i].getAttribute('id'),parentID);
            break;
        }
        else if (id !== '') {
            bounce(id,selectBoxes[i].getAttribute('id'));
            break;
        }
    }
});

// "Bounces" the user to the correct JSON URL
function bounce(id,contentType,parentID) {
    var URL = '';
    // shopify.dev/docs/admin-api/rest/reference/metafield
    switch (contentType) {
        case 'variants':
            URL = '/admin/products/' + parentID + '/' + contentType + '/' + id + '/metafields.json';
            break;
        case 'articles':
            URL = '/admin/blogs/' + parentID + '/' + contentType + '/' + id + '/metafields.json';
            break;
        default:
            URL = '/admin/' + contentType + '/' + id + '/metafields.json';
    }
    document.location.href = '//' + document.location.hostname + URL;
}

This JavaScript loops through the values of the select boxes (marked with a data-js="auto" attribute) until it finds one which isn't empty. It takes the id from that select box, plus the value (which, confusingly, is the id of that particular content item), then passes them onto a function called bounce().

So what does this do, exactly?

This allows you to see all of the metafields for a particular content item, in JSON format, or as I like to think of it, the poor man's XML.

We can, in theory, add metafields to a lot more types of content than the five this form allows you to see. Specifically:

  • Blog (because you can have more than one blog on your site)
  • Customer
  • Draft Order
  • Order
  • Product Image

However, during my testing, I couldn't successfully add metafields to all of these types of data, which suggests it's impossible, using just the URL interface.

How do I get this to appear in the front end?

So this is all great fun, but what use is it, unless we can burp this content out at the user? Let's have a look at some example JSON generated by metafields:

{
  "metafields": [
      {
          "id": 12345678901233,
          "namespace": "faq",
          "key": "q-one",
          "value": "Is this suitable for a man over fifty?",
          "value_type": "string",
          "description": null,
          "owner_id": 1234567890123,
          "created_at": "2020-12-08T11:01:54-05:00",
          "updated_at": "2020-12-08T11:01:54-05:00",
          "owner_resource": "product"
      },
      {
          "id": 12345678901234,
          "namespace": "faq",
          "key": "a-one",
          "value": "Yes - there are no offensive gifts inside",
          "value_type": "string",
          "description": null,
          "owner_id": 1234567890123,
          "created_at": "2020-12-08T11:01:54-05:00",
          "updated_at": "2020-12-08T11:01:54-05:00",
          "owner_resource": "product"
      }
  ]
}

We could edit our product.liquid template to reflect this on the front end like this:

{% comment %}
Check if there's anything in our FAQ namespace
{% endcomment %}
{% if product.metafields.faq.size > 0 %}

  {% unless product.metafields.faq.q-one == blank %}
    <h2>{{ product.metafields.faq.q-one }}</h2>
  {% endunless %}

  {% unless product.metafields.faq.a-one == blank %}
    <p>{{ product.metafields.faq.a-one }}</p>
  {% endunless %}

{% endif %}

(I enjoy the perversity of using unless tags, because every other language just makes use of if)

Let's take a closer look at the business-end of that liquid tag:

A liquid tag which orders the data as follows: data type, metafields, namespace and metafield key. Each value is separated with full-stops.

Here's another example: what if you added an image metafield to pages, in the global namespace? The tag for that would be:

{{ page.metafields.global.image }}

OK, I've done that. Now how do I delete metadata?

We've been using the get method of forms to sort of fake API access up until now. The get and post form methods mirror HTTP functionality, which also includes another method, delete. There was a brief moment when Firefox supported the delete method. But it looks like it's not going to become part of a standard anytime soon.

However, if you edit an old metafield and then remove its content, it will disappear from the JSON.

Conclusion

While these tools are useful, having your CMS authors creating endless metafields and populating them with content is meaningless without this data being reflected in the front end. What's probably more useful for these folk is the generated URL which lets them bulk-edit all of your products or whatnot.

Get in touch to find out more     We turn casual browsers into committed buyers