# Taxonomies

> Classify content, query locale-aware terms, and build taxonomy archive pages.

Source: https://emdashcmseverything.com/official-docs/guides/taxonomies/

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 one term to several posts

Editors can select posts in a collection list and click **Add** followed by the taxonomy's singular name (for example, **Add tag**), or open a taxonomy's page and click **Add to posts** to paste public post URLs (one per line, up to 50). When more than one taxonomy applies to the collection, the list shows **Add term**, and the dialog asks which taxonomy to use. Choose an existing term or create one in the dialog, then click **Review posts**. Check every matched title and language, then click the button showing how many posts will be updated. Links that do not exactly match a published post on this site are flagged rather than guessed. Only entries in collections that use the chosen taxonomy can be matched.

Adding the term takes effect immediately, even when a post has other unpublished draft edits; it does not publish those edits. Existing terms remain in place, and posts that already have the term are skipped. The result list shows which posts were updated, skipped, or could not be updated; use **Retry failures** for write failures. URL matching uses the site's configured public origin and collection URL patterns, including date and language paths.

## 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:

```ts
import { getEmDashCollection, getTaxonomyTerms } from "emdash";

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

## Delete a taxonomy

Open the taxonomy in the admin, then choose **Delete taxonomy** from the actions menu in the page header and confirm. The action requires the `taxonomies:manage` permission, which editors and administrators hold.

Deleting a taxonomy deletes its terms in every language and removes those terms from the content filed under them. The content entries themselves are kept.

> **Caution**
>
> Deleting a taxonomy cannot be undone. Templates that query the deleted name keep rendering but show
> nothing: `getTaxonomyTerms()` returns an empty array, and a `where` filter on the name returns no
> entries. Update those queries and the archive pages that depend on them.

## 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:

```astro title="src/components/CategoryList.astro"
---
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:

```astro title="src/pages/category/[slug].astro"
---
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.rewrite("/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:

```astro title="src/components/PostTerms.astro"
---
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.

A taxonomy definition splits its fields between the taxonomy and each locale:

| Field | Belongs to | Effect |
| --- | --- | --- |
| `name` | Taxonomy | Fixed after creation. Every locale's definition uses the same name. |
| `hierarchical`, `collections` | Taxonomy | The same in every locale. Changing either through any locale changes it for all locales. |
| `label`, `labelSingular` | Locale | Each locale's definition has its own. |

A definition created for a name that already exists in another locale joins that taxonomy, with or without `translationOf`, and takes its `hierarchical` and `collections`. Creating it with different values fails; change them with an update. A locale without its own definition still lists the taxonomy's terms and shows the label of the first locale on its fallback chain that has one, else the default locale's label, else the label of the locale with the lowest locale code.

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.

A term's parent and position are shared by every locale. A translation created without a parent takes its term's parent and position. Creating a translation under a different parent, or changing the parent through any locale, moves the term in every locale.

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.

> **Tip**
>
> Keep taxonomy names stable across locales. Translate the definition label, term labels, and term
> slugs; templates continue to query the same name, such as `category`.

See [Internationalization](https://emdashcmseverything.com/official-docs/guides/internationalization/) for locale routing and fallback configuration and [Working with Content](https://emdashcmseverything.com/official-docs/guides/working-with-content/) for editing entries. The [runtime API reference](https://emdashcmseverything.com/official-docs/reference/api/#taxonomies) 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](https://emdashcmseverything.com/official-docs/reference/rest-api/#taxonomy-endpoints) for request bodies and responses.
