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.
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.
- Log into your Ghost Admin
- Go to Settings > Integrations
- Scroll down to Custom Integrations and click Add custom integration
- Name it something memorable, like "Tags Page"
- Copy the Content API key
Step 2: Create the Page
- Go to Pages > New page
- Give it a title like "Browse by Topic"
- 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'sSLUGvariable in Step 3) - Add some placeholder text like "Loading tags..."
- 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 keySLUG: set this to the URL slug of the page you created in Step 2SELECTOR: 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 |