How to Create a "Browse by Topic" Page for Your Ghost Blog

Renders a linked list of every public tag on the site into a published Ghost page.

Share

Ghost gives every tag its own archive page, but there's no built-in way to show visitors all your tags in one place. This post describes how to create a page on your site listing every public tag as a clickable link with post counts, sorted alphabetically. The best part: no theme edits required, so a theme update can't wipe out your work.

Step 1: Get a Content API Key

Content API keys are read-only: they can only fetch your already-published content, which is exactly what public site visitors can already see. Ghost designs them to be safe for use in client-side JavaScript, which is what makes this whole approach possible.

  1. Log into your Ghost Admin
  2. Go to Settings > Integrations
  3. Scroll down to Custom Integrations and click Add custom integration
  4. Name it something memorable, like "Tags Page"
  5. Copy the Content API key

Step 2: Create the Page

  1. Go to Pages > New page
  2. Give it a title like "Browse by Topic"
  3. In the page settings panel, check the URL and set it to browse-by-topic (or anything you like; whatever you choose goes into the script's SLUG variable in Step 3)
  4. Add some placeholder text like "Loading tags..."
  5. Publish the page

Step 3: Add the Script

Go to Page settings > Code Injection and paste this into the Page footer:

<!-- Renders a linked list of every public tag on the site into a published Ghost page. -->

<script>
  (function() {
    var KEY = 'YOUR_CONTENT_API_KEY';  // Content API key
    var SLUG = 'browse-by-topic';      // URL of the page this script should run on
    var SELECTOR = '.gh-content';      // adjust to your theme's page-body class

    // Escape regex metacharacters in the slug
    var escapedSlug = SLUG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    var pattern = new RegExp('^\\/' + escapedSlug + '\\/?$');
    if (!pattern.test(window.location.pathname)) return;

    var url = window.location.origin + '/ghost/api/content/tags/' +
      '?key=' + KEY + '&limit=all&include=count.posts&order=name%20ASC';

    fetch(url)
      .then(function(res) {
        if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + res.statusText);
        return res.json();
      })
      .then(function(data) {
        var tags = data.tags || [];
        var el = document.querySelector(SELECTOR);
        if (!el) {
          console.warn('[tags-page] Selector "' + SELECTOR + '" not found');
          return;
        }

        el.innerHTML = '';  // remove the placeholder text

        var ul = document.createElement('ul');
        ul.className = 'all-tags-list';

        tags.forEach(function(tag) {
          // Skip tags with no public posts
          if (!tag.count || !tag.count.posts) return;

          var li = document.createElement('li');
          var a = document.createElement('a');
          a.href = tag.url;
          a.textContent = tag.name + ' (' + tag.count.posts + ' posts)';
          li.appendChild(a);
          ul.appendChild(li);
        });

        el.appendChild(ul);
        console.log('[tags-page] Rendered ' + ul.children.length + ' tags');
      })
      .catch(function(err) {
        console.error('[tags-page] Failed:', err);
      });
  })();
</script>

Make three adjustments, all in the config block at the top of the script:

  • KEY: paste in your Content API key
  • SLUG: set this to the URL slug of the page you created in Step 2
  • SELECTOR: your theme's page-content class (covered in Step 4)

Step 4: Find Your Theme's Content Class

The SELECTOR variable tells the script where to inject the list. Most modern Ghost themes use .gh-content for page body content, but yours may differ.

To check: open your new page, press F12 to open DevTools, right-click your placeholder text, and choose Inspect. Look at the class on the wrapping element. If you see something like page-content or post-content instead, swap it into the SELECTOR line.

A note for crawlers

The tag list is rendered client-side, so it only exists once JavaScript runs. Human visitors get the full page, but many search engine and AI crawlers don't execute JavaScript and will only ever see your placeholder text. I ran into this firsthand: an AI crawler indexed my own tags page with the description "Loading tags...".

If being legible to crawlers matters to you, keep a manual list of your tags as permanent page content (a plain bulleted list of links). The script replaces it with the live version for human visitors, so nobody sees a stale list, but crawlers always have something real to read. The tradeoff is updating the manual list when you add or remove a tag.

Troubleshooting

If the page stays empty, open DevTools (F12, Console tab) and check what the script logged:

HTTP 401/403 Key is wrong, or you copied the Admin API key instead of the Content API key Re-copy the Content API key from your custom integration
Selector ".gh-content" not found Theme wraps page content in a different class Swap the class into SELECTOR (see Step 4)
Nothing logged at all SLUG doesn't match your page's URL Verify the URL in the page settings panel
A tag is missing from the list Internal tags (names starting with #) are excluded by design; or the tag has no public posts Neither is a bug — internal tags aren't publicly visible, and empty tags are skipped
Tag list loads but the API call uses the wrong base path Ghost installed under a subdirectory, not the domain root Change the API URL to include your subdirectory