Navigation Menus

On this page

EmDash menus are ordered lists of links that you manage through the admin interface. Menus support nesting for dropdowns and can link to pages, posts, taxonomy terms, or external URLs.

Querying Menus

Use getMenu() to fetch a menu by its unique name:

---
import { getMenu } from "emdash";

const primaryMenu = await getMenu("primary");
---

{primaryMenu && (
  <nav>
    <ul>
      {primaryMenu.items.map(item => (
        <li>
          <a href={item.url}>{item.label}</a>
        </li>
      ))}
    </ul>
  </nav>
)}

The function returns null if no menu exists with that name.

A menu contains metadata and an array of items:

interface Menu {
	id: string;
	name: string; // Unique identifier ("primary", "footer")
	label: string; // Display name ("Primary Navigation")
	items: MenuItem[];
}

interface MenuItem {
	id: string;
	label: string;
	url: string; // Resolved URL
	target?: string; // "_blank" for new window
	titleAttr?: string; // HTML title attribute
	cssClasses?: string; // Custom CSS classes
	children: MenuItem[]; // Nested items for dropdowns
}

URLs are resolved automatically based on the item type:

  • Page/Post items resolve to /{collection}/{slug}
  • Taxonomy items resolve to /{taxonomy}/{slug}
  • Collection items resolve to /{collection}/
  • Custom links use the URL as-is

Rendering Nested Menus

Menu items can have children for dropdown navigation. Handle nesting by recursively rendering the children array:

---
import { getMenu } from "emdash";
import type { MenuItem } from "emdash";

interface Props {
  name: string;
}

const menu = await getMenu(Astro.props.name);
---

{menu && (
  <nav class="nav">
    <ul class="nav-list">
      {menu.items.map(item => (
        <li class:list={["nav-item", item.cssClasses]}>
          <a
            href={item.url}
            target={item.target}
            title={item.titleAttr}
            aria-current={Astro.url.pathname === item.url ? "page" : undefined}
          >
            {item.label}
          </a>
          {item.children.length > 0 && (
            <ul class="submenu">
              {item.children.map(child => (
                <li>
                  <a href={child.url} target={child.target}>
                    {child.label}
                  </a>
                </li>
              ))}
            </ul>
          )}
        </li>
      ))}
    </ul>
  </nav>
)}

The admin supports five types of menu items:

TypeDescriptionURL Resolution
pageLink to a page/{collection}/{slug}
postLink to a post/{collection}/{slug}
taxonomyLink to a category or tag/{taxonomy}/{slug}
collectionLink to a collection archive/{collection}/
customExternal or custom URLUsed as-is

Listing All Menus

Use getMenus() to retrieve all menu definitions (without items):

import { getMenus } from "emdash";

const menus = await getMenus();
// Returns: [{ id, name, label }, ...]

This is primarily useful for admin interfaces or debugging.

Creating Menus

Create menus through the admin interface at /_emdash/admin/menus, or use the admin API:

POST /_emdash/api/menus
Content-Type: application/json

{
  "name": "footer",
  "label": "Footer Navigation"
}

Add items to a menu:

POST /_emdash/api/menus/footer/items
Content-Type: application/json

{
  "type": "page",
  "referenceCollection": "pages",
  "referenceId": "page_privacy",
  "label": "Privacy Policy"
}

Add a custom external link:

POST /_emdash/api/menus/footer/items
Content-Type: application/json

{
  "type": "custom",
  "customUrl": "https://github.com/example",
  "label": "GitHub",
  "target": "_blank"
}

Reordering and Nesting

Update item order and parent-child relationships with the reorder endpoint:

POST /_emdash/api/menus/primary/reorder
Content-Type: application/json

{
  "items": [
    { "id": "item_1", "parentId": null, "sortOrder": 0 },
    { "id": "item_2", "parentId": null, "sortOrder": 1 },
    { "id": "item_3", "parentId": "item_2", "sortOrder": 0 }
  ]
}

This makes item_3 a child of item_2, creating a dropdown.

Complete Example

The following example shows a responsive header with primary navigation:

---
import { getMenu, getSiteSettings } from "emdash";

const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---

<html lang="en">
  <head>
    <title>{settings.title}</title>
  </head>
  <body>
    <header class="header">
      <a href="/" class="logo">
        {settings.logo ? (
          <img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
        ) : (
          settings.title
        )}
      </a>

      {primaryMenu && (
        <nav class="main-nav" aria-label="Main navigation">
          <ul>
            {primaryMenu.items.map(item => (
              <li class:list={[item.cssClasses, { "has-children": item.children.length > 0 }]}>
                <a
                  href={item.url}
                  target={item.target}
                  aria-current={Astro.url.pathname === item.url ? "page" : undefined}
                >
                  {item.label}
                </a>
                {item.children.length > 0 && (
                  <ul class="dropdown">
                    {item.children.map(child => (
                      <li>
                        <a href={child.url} target={child.target}>{child.label}</a>
                      </li>
                    ))}
                  </ul>
                )}
              </li>
            ))}
          </ul>
        </nav>
      )}
    </header>

    <main>
      <slot />
    </main>
  </body>
</html>

API Reference

getMenu(name)

Fetch a menu by name with all items and resolved URLs.

Parameters:

  • name — The menu’s unique identifier (string)

Returns: Promise<Menu | null>

getMenus()

List all menu definitions without items.

Returns: Promise<Array<{ id: string; name: string; label: string }>>