One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Building a tag system in Astro
Cover image: Four paper card tags on a dark background — Photo by Angèle Kamp on Unsplash
astro engineering web development

Building a tag system in Astro

Slug normalisation, tag clouds, ring layouts, paginated archives, and tag page improvements

Published
21 April 2026
Read time
27 min read
SeriesPart of How this blog was built — documenting every decision that shaped this site.

Tags do more than connect related posts. They create a second navigation system. On this site the same tag data powers the blog archive cloud, the dedicated /tags overview, paginated tag archive pages, and a sidebar browser on individual post pages.

The key constraint is consistency. If each surface counts tags differently, builds URLs differently, or decides for itself which posts are visible, the whole system drifts. The implementation here stays simple by sharing two rules everywhere: one slug helper and one publication filter.

A later update added per-tag descriptions, a featured post highlight, related topics, and a stats bar, all covered in the second half of this post.

Tag system wireframe showing three views: the blog archive weighted cloud with tier-1/2/3 pill sizes, the /tags page with concentric ring layout, and the post sidebar tag list

Slug normalisation

Every tag is displayed as-is, but routed via a URL-safe slug. A small utility in src/utils/tags.ts handles that conversion:

src/utils/tags.ts
export function tagSlug(tag: string): string {
return tag
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}

That turns "web development" into "web-development", "Node.js" into "nodejs", and "C#" into "c". It is intentionally small. A more generic utility might transliterate Unicode or preserve special cases, but the tag vocabulary on this site is controlled enough that a simple transform is easier to reason about.

The important detail is reuse. The cloud component, sidebar, tags index, and tag archive routes all import the same helper, so every link resolves to the same path format.

Publication-aware tag counts

Counting tags is trivial. Counting the right tags is the subtle part.

This site does not let each tag surface invent its own visibility rules. It filters posts through the shared publication helper and only then derives tag counts:

src/components/BlogTagCloud.astro
import { isPublished } from "../utils/drafts";
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagCounts = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}

That matters because isPublished() hides both drafts and future-dated posts unless SHOW_DRAFTS=true. The weighted cloud, ring layout, tag archives, and sidebar all stay in sync with the rest of the site because they start from the same filtered set.

Right now the counting logic is repeated across several files. That is still a good trade-off: the logic is tiny and the behaviour stays obvious. If the rules become more complex later, extracting a shared getTagCounts() helper would be the next clean-up step.

The weighted cloud on the blog archive

The weighted cloud lives on the blog archive: /blog and the paginated /blog/page/[page] pages both render the same component.

src/pages/blog/index.astro
---
import BlogTagCloud from "../../components/BlogTagCloud.astro";
// ...
---
<BlogTagCloud />

Inside BlogTagCloud.astro, counts are converted into three visual tiers:

src/components/BlogTagCloud.astro
function tier(count: number): number {
if (maxCount === minCount) return 2;
const ratio = (count - minCount) / (maxCount - minCount);
if (ratio >= 0.66) return 3;
if (ratio >= 0.33) return 2;
return 1;
}

Those tiers map to modifier classes such as tag-cloud__pill--tier-1, tag-cloud__pill--tier-2, and tag-cloud__pill--tier-3. A stepped system is deliberate here. Continuous scaling works well on the more expressive /tags page, but in a compact cloud of pill links it creates visual noise faster than it adds meaning.

Each pill also includes a small count badge, so someone hovering or scanning the cloud sees both the qualitative weight and the exact number. An “All topics” link pushes people from the compact archive cloud into the more detailed /tags overview.

The concentric ring layout on /tags

The /tags page is the more exploratory view. It combines two separate signals:

  • Font size scales continuously with tag frequency.
  • Tags are placed on concentric rings, with the most common topic in the centre and less common topics pushed outward.

The scaling and ring layout are both calculated at build time in the page frontmatter:

src/pages/tags/index.astro
const MIN_REM = 1.1;
const MAX_REM = 4;
function scale(count: number): number {
if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2;
return (
MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM)
);
}
const ringConfig = [
{ radiusPct: 0, max: 1, startDeg: 0 },
{ radiusPct: 18, max: 4, startDeg: -90 },
{ radiusPct: 35, max: 7, startDeg: -50 },
{ radiusPct: 48, max: 12, startDeg: 10 },
];

After sorting tags by count descending, the page fills the rings from the inside out and converts each ring slot into percentage coordinates:

src/pages/tags/index.astro
const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length;
const rad = (angleDeg * Math.PI) / 180;
const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad);
const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad);

That produces an SVG-like layout without SVG. Each tag is still a normal anchor, just absolutely positioned inside a relative container.

The accessibility model matters here too. The circular cloud is decorative and marked aria-hidden, while a plain list remains in the accessibility tree and is also the visual fallback on small screens. The fallback list is shuffled with a seeded function so it does not read as a rigid alphabetical index, but it stays deterministic across builds.

Tag archive pages

Each topic gets a clean first-page URL at /tags/[tag] and subsequent archive pages at /tags/[tag]/2, /tags/[tag]/3, and so on.

Rather than using Astro’s paginate() helper, the current implementation keeps two route files:

  • src/pages/tags/[tag]/index.astro handles page 1.
  • src/pages/tags/[tag]/[page].astro emits pages 2 and above.

The second file builds its paths manually:

src/pages/tags/[tag]/[page].astro
export async function getStaticPaths() {
const PAGE_SIZE = 9;
const allPosts = (await getCollection("posts")).filter(isPublished);
const tagMap = new Map<string, typeof allPosts>();
for (const post of allPosts) {
for (const tag of post.data.tags) {
const slug = tagSlug(tag);
if (!tagMap.has(slug)) tagMap.set(slug, []);
tagMap.get(slug)!.push(post);
}
}
const paths: object[] = [];
for (const [slug, posts] of tagMap.entries()) {
const totalPages = Math.ceil(posts.length / PAGE_SIZE);
const label = posts[0].data.tags.find((t) => tagSlug(t) === slug) ?? slug;
for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
paths.push({
params: { tag: slug, page: String(pageNum) },
props: { label },
});
}
}
return paths;
}

Routes use slugs, but page heroes should still display the human-readable tag text. Looking up the original label from the first matching post preserves spaces and casing without a separate lookup table.

Both route files sort posts newest first and share the same PAGE_SIZE = 9, so pagination behaves consistently across every topic.

The sidebar browser

TagsSidebar.astro is the compact version of the same system. It keeps the same counting and sorting pattern, but renders a simple vertical list with count badges rather than a cloud:

src/components/TagsSidebar.astro
const tags = [...tagCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([tag, count]) => ({ tag, count, slug: tagSlug(tag) }));

The sidebar only matters because the post layout mounts it alongside the table of contents and share controls:

src/layouts/MarkdownPostLayout.astro
import TagsSidebar from "../components/TagsSidebar.astro";
<aside class="post__sidebar" aria-label="Post sidebar">
{/* other sidebar blocks */}
<TagsSidebar />
</aside>

That keeps topic browsing available even when someone lands deep on an individual article. The UI copy uses “topics” in the reader-facing labels, but the routes stay under /tags, which keeps the underlying implementation short and stable.

Pulling the system together

The interesting part of this tag system is not any single surface. It is the fact that every surface starts from the same visible post set and uses the same slug transform. Once those two rules are stable, the same dataset can show up as weighted pills, a ring cloud, paginated archives, or a sidebar list without the site disagreeing with itself.

Added 23 April 2026

Improving the tag pages

A comment from Fyodor after this post was first published put it directly:

Now try to add description to each tag so that a tag page wouldn’t look that generically bland, huh?

Fair point. I went ahead and added four improvements to address exactly that.

Tag descriptions

A tagDescriptions map in src/utils/tags.ts keys a short description to each tag slug:

src/utils/tags.ts
export const tagDescriptions: Record<string, string> = {
astro: "Everything about building with Astro: content collections, islands architecture, SSG, and the surrounding ecosystem.",
engineering: "Software engineering practice — architecture, trade-offs, and the thinking behind building things well.",
// ...
};

The full map covering all current tags is in the code listings at the end of this post.

The tag archive page reads the current slug and falls back gracefully if no description exists:

src/pages/tags/[tag]/index.astro
const description = tagDescriptions[tag];

That value then drives the <meta> description, the hero subtitle, and the panel copy. One source, three uses. New tags that lack an entry get the old generic fallback, so nothing breaks.

Featured post highlight

The most recent post was buried in the grid with no visual prominence. The fix pulls it out entirely and renders it as a full-width two-column card above the grid:

src/pages/tags/[tag]/index.astro
const featuredPost = allTagPosts[0];
const remainingPosts = allTagPosts.slice(1);
const totalPages = Math.ceil(remainingPosts.length / PAGE_SIZE);
const posts = remainingPosts.slice(0, PAGE_SIZE);

The featured card shows the cover image on the left at desktop size with the title, description, and a read CTA on the right. totalPages is derived from remainingPosts, not allTagPosts, so pagination stays honest.

Between the featured card and the grid, a compact pill row links to up to five related tags, specifically ones that most frequently co-occur with the current one:

src/pages/tags/[tag]/index.astro
const relatedTagCounts = new Map<string, number>();
for (const post of allTagPosts) {
for (const t of post.data.tags) {
const s = tagSlug(t);
if (s !== tag) relatedTagCounts.set(s, (relatedTagCounts.get(s) ?? 0) + 1);
}
}
const relatedTags = [...relatedTagCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([slug]) => ({
slug,
label: allTagPosts.flatMap((p) => p.data.tags).find((t) => tagSlug(t) === slug) ?? slug,
}));

This runs entirely at build time, with no JavaScript in the browser.

Stats bar

Inside the topic overview panel, a <dl> shows three facts: post count, average reading time per post, and total reading time across the whole archive.

src/pages/tags/[tag]/index.astro
const totalWords = allTagPosts.reduce(
(acc, p) => acc + (p.body ? readingTime(p.body).words : 0),
0,
);
const totalReadMinutes = Math.ceil(totalWords / 200);
const avgReadMinutes = allTagPosts.length > 0
? Math.round(totalReadMinutes / allTagPosts.length)
: 0;

The 200 wpm divisor is deliberately conservative: it errs on the side of not under-promising. The stat gives a quick sense of how deep the archive goes before committing to reading any of it.

Beyond the foundations

All four improvements build on the same two rules without touching them. The slug helper and publication filter stay unchanged. What changes is only what each tag page does with the data once it has it.

That is a useful sign. When enhancements layer on top without revisiting the core logic, the foundation was right.

Full code listings

If you want to inspect the finished implementation end to end, expand the listings below. These are the complete files that make up the tag system itself. The shared publication helper lives in src/utils/drafts.ts and is reused across the rest of the site too.

You can browse the rest of the site code in the sourcier.uk repository.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.