Skip to content

Navigation Menus

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.

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

src/layouts/Base.astro
---
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

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

src/components/Navigation.astro
---
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

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.

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"
}

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.

The following example shows a responsive header with primary navigation:

src/layouts/Base.astro
---
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>

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

Parameters:

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

Returns: Promise<Menu | null>

List all menu definitions without items.

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