Taxonomies

On this page

A taxonomy is a named classification applied to one or more collections. EmDash starts with the hierarchical category taxonomy and the flat tag taxonomy for posts. A site can also define taxonomies such as genre, topic, or difficulty.

Terms belong to a taxonomy. A category such as “Guides” can have child categories, while tags and other flat taxonomies have one level.

Manage terms

Open a taxonomy from Taxonomies in the EmDash admin.

  1. Click Add Category, Add Tag, or the equivalent action for the current taxonomy.

  2. Enter the label and slug. For a hierarchical taxonomy, choose a parent when the term belongs below another term.

  3. Add an optional description, then create the term.

  4. Use the move controls in the term list to set the order within the current parent group.

Editors assign terms from the taxonomy panels in a content entry. The taxonomy definition controls which collections show each panel.

Deleting a term removes its assignments from content. It does not delete the content entries.

Add a custom taxonomy

Create a taxonomy when an existing collection needs a separate classification.

  1. Open Taxonomies and click New Taxonomy.

  2. Enter a label and a stable name. Names start with a lowercase letter and contain only lowercase letters, numbers, and underscores.

  3. Enable Hierarchical if terms need parent and child relationships.

  4. Select every collection that can use the taxonomy, then click Create Taxonomy.

  5. Add the initial terms and assign them to content.

Templates query the stable name. Changing a display label does not require a template change.

Custom taxonomies use the same query and filter helpers as categories and tags. The following example reads the genre terms and filters books by one of their slugs:

import { getEmDashCollection, getTaxonomyTerms } from "emdash";

const genres = await getTaxonomyTerms("genre", { includeCounts: false });
const { entries: scienceFictionBooks } = await getEmDashCollection("books", {
  where: { genre: "science-fiction" },
});

Query a term list

Use getTaxonomyTerms() to render a taxonomy index, navigation list, or set of filters. Hierarchical taxonomies return a tree through each term’s children array.

Term counts are included by default and require an aggregate across the taxonomy’s assigned collections. Skip that work when the component does not display counts.

The following component renders category links without counts:

---
import { getTaxonomyTerms } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";

const locale = Astro.currentLocale;
const categories = await getTaxonomyTerms("category", {
  locale,
  includeCounts: false,
});

function categoryHref(slug: string) {
  const path = `/category/${slug}`;
  return locale ? getRelativeLocaleUrl(locale, path) : path;
}
---

<nav aria-label="Categories">
  <ul>
    {categories.map((category) => (
      <li>
        <a href={categoryHref(category.slug)}>{category.label}</a>
        {category.children.length > 0 && (
          <ul>
            {category.children.map((child) => (
              <li><a href={categoryHref(child.slug)}>{child.label}</a></li>
            ))}
          </ul>
        )}
      </li>
    ))}
  </ul>
</nav>

When a component displays usage, omit includeCounts: false and render term.count. The count includes publicly visible entries in the locale used for the query.

Build a taxonomy archive

Decode a dynamic route parameter before looking up a term. Query the term and content with the same locale, and pass generated paths through Astro’s locale URL helper.

The following route lists published posts in one category:

---
import { decodeSlug, getEmDashCollection, getTerm } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";
import Base from "../../layouts/Base.astro";

const locale = Astro.currentLocale;
const slug = decodeSlug(Astro.params.slug);
const category = slug
  ? await getTerm("category", slug, { locale, includeCounts: false })
  : null;

if (!category) {
  return Astro.redirect("/404");
}

const { entries: posts } = await getEmDashCollection("posts", {
  status: "published",
  locale,
  where: { category: category.slug },
  orderBy: { published_at: "desc" },
});

function postHref(postSlug: string) {
  const path = `/posts/${postSlug}`;
  return locale ? getRelativeLocaleUrl(locale, path) : path;
}
---

<Base title={`${category.label} posts`}>
  <h1>{category.label}</h1>
  {category.description && <p>{category.description}</p>}

  {posts.length > 0 ? (
    <ul>
      {posts.map((post) => (
        post.data.slug && (
          <li>
            <a href={postHref(post.data.slug)}>{post.data.title}</a>
          </li>
        )
      ))}
    </ul>
  ) : (
    <p>No posts in this category.</p>
  )}
</Base>

where uses the taxonomy name as its key and a term slug as its value. Query sort identifiers use database field names such as published_at; entry data exposes the corresponding value as publishedAt.

Use the collection’s actual public route in postHref(). If the collection uses a custom urlPattern, build links from that pattern rather than assuming /posts/{slug}.

Display an entry’s terms

getEmDashEntry() and getEmDashCollection() hydrate assigned terms onto entry.data.terms. Read that value instead of running one getEntryTerms() query for every entry in a list.

The following component renders categories and tags already loaded with a post:

---
import type { ContentEntry, InferCollectionData } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";

interface Props {
  post: ContentEntry<InferCollectionData<"posts">>;
}

const { post } = Astro.props;
const locale = Astro.currentLocale;
const categories = post.data.terms?.category ?? [];
const tags = post.data.terms?.tag ?? [];

function termHref(taxonomy: string, slug: string) {
  const path = `/${taxonomy}/${slug}`;
  return locale ? getRelativeLocaleUrl(locale, path) : path;
}
---

{categories.length > 0 && (
  <ul aria-label="Categories">
    {categories.map((category) => (
      <li>
        <a href={termHref("category", category.slug)}>{category.label}</a>
      </li>
    ))}
  </ul>
)}

{tags.length > 0 && (
  <ul aria-label="Tags">
    {tags.map((tag) => (
      <li>
        <a href={termHref("tag", tag.slug)}>{tag.label}</a>
      </li>
    ))}
  </ul>
)}

Use getEntryTerms() when all you have is a collection name and entry ID. Use getTermsForEntries() to batch terms for several entries when they were not hydrated by the content query.

Translate taxonomies and terms

Taxonomy definitions and terms have one row per locale. EmDash records which rows are translations of the same taxonomy or term. Content assignments use that shared identity, so an assignment made in one locale resolves to the translated term in another locale when one exists.

Use the locale switcher on a taxonomy page to manage terms in each configured locale. Open a term’s edit dialog and use its Translations panel to add or open another locale. A translated term can use a different slug and label.

The query helpers use an explicit locale when supplied. Otherwise they use the current request locale, then the configured default. Single-term lookups follow the configured fallback chain when the requested translation is absent.

See Internationalization for locale routing and fallback configuration and Working with Content for editing entries. The runtime API reference documents taxonomy query helpers. For programmatic changes, authenticate with a Bearer token and add X-EmDash-Request: 1 to every state-changing request. See the taxonomy endpoints for request bodies and responses.