DEV Community

Cover image for Fetch & Build your Navigation
Marvin Bangemann
Marvin Bangemann

Posted on • Originally published at bangemann.dev

Fetch & Build your Navigation

Previous Posts:

  1. Introduction
  2. Setup & Configuration

Crafting an Effective Navigation for Your Astro Website

Welcome to a pivotal installment in our series where we dive into creating a dynamic navigation for your Astro-based website. The inspiration for this series was sparked by community interest on a Reddit post, encouraging me to share my experiences and tackle the imposter syndrome head-on. Let's explore the rationale behind this approach: it empowers your marketing team (or whoever manages the website's backend) to adjust navigation items, including order, addition, and removal of subitems, directly within WordPress. This functionality is a game-changer for those seeking flexibility and ease of management.

Configuring Your WordPress Backend for Navigation Management

Following up on a promise from my previous post, we'll delve deeper into registering a new endpoint for navigation items. This is achieved by adding the following snippet to your get_menu.php file:

add_action('rest_api_init', function () {
    register_rest_route('wp/v2', '/menu/(?P<menu_name>[\w-]+)', array(
        'methods' => 'GET',
        'callback' => 'wp_rest_get_menu',
        'permission_callback' => '__return_true',
        'args' => array(
            'menu_name' => array(
                'validate_callback' => function ($param, $request, $key) {
                    // Additional validation logic if needed
                    return is_string($param) && strlen($param) <= 20; // Example: Ensure it's a string and not longer than 20 characters
                },
                'sanitize_callback' => 'sanitize_text_field',
            ),
        ),
    ));
});

Enter fullscreen mode Exit fullscreen mode

This code snippet effectively makes your menus accessible via the REST API, adhering to the WordPress REST API schema. You can fetch menu items by visiting https://your-wordpress-url.com/wp-json/wp/v2/menu/menu-endpoint, with "menu-endpoint" being the identifier you set in your menus configuration.

To streamline menu management on the Astro frontend, we introduce an enumeration for different menus:

const GetNavigationParamsSchema = z.object({
  endpoint: z.enum(['main-navigation', 'sub-navigation', 'footer-navigation', 'footer-sub-navigation']),
});

export type GetNavigationParams = z.infer<typeof GetNavigationParamsSchema>;
Enter fullscreen mode Exit fullscreen mode

Typing Your Menus in Astro with Zod

Ensuring type safety and data integrity is crucial, hence why we define our request and response types using Zod. This aids in processing the expected data without errors. Here's how to structure your navigation item data schema:

// Base schema for navigation item data, without the children property
const baseNavigationItemDataSchema = z.object({
  ID: z.number(),
  post_author: z.string().optional(),
  post_date: z.string().optional(),
  post_date_gmt: z.string().optional(),
  post_content: z.string().optional(),
  post_title: z.string().optional(),
  post_excerpt: z.string().optional(),
  post_status: z.string().optional(),
  comment_status: z.string().optional(),
  ping_status: z.string().optional(),
  post_password: z.string().optional(),
  post_name: z.string().optional(),
  to_ping: z.string().optional(),
  pinged: z.string().optional(),
  post_modified: z.string().optional(),
  post_modified_gmt: z.string().optional(),
  post_content_filtered: z.string().optional(),
  post_parent: z.number().optional(),
  guid: z.string().optional(),
  menu_order: z.number().optional(),
  post_type: z.string().optional(),
  post_mime_type: z.string().optional(),
  comment_count: z.string().optional(),
  filter: z.string().optional(),
  db_id: z.number().optional(),
  menu_item_parent: z.string().optional(),
  object_id: z.string().optional(),
  object: z.string().optional(),
  type: z.string().optional(),
  type_label: z.string().optional(),
  url: z.string().optional(),
  title: z.string().optional(),
  target: z.string().optional(),
  attr_title: z.string().optional(),
  description: z.string().optional(),
  classes: z.array(z.string()).optional(),
  xfn: z.string().optional(),
});
Enter fullscreen mode Exit fullscreen mode

This is the Reponse we get from our wordpress backend when we call the menu api. It's a lot of different stuff, we don't really need for our frontend. So we can use zod to define the data we want to use, to guarantee type-safety inside our Components later.

export const baseNavigationItemSchema = z.object({
  title: z.string(),
  url: z.string(),
  ID: z.number(),
  menu_order: z.number(),
  menu_item_parent: z.string(),
  type: z.string(),
  object: z.string(),
  post_status: z.string(),
});
Enter fullscreen mode Exit fullscreen mode

Looks cleaner, right? But we aren't done yet. To accommodate navigation hierarchies, we extend our base schema to include child items, leveraging Zod's capabilities for recursive types: Here's how I did it, according to Zods documentation:

const navigationItemSchema = baseNavigationItemSchema.extend({
  children: z.array(z.lazy(() => baseNavigationItemSchema)).optional(),
});
Enter fullscreen mode Exit fullscreen mode

Now we can define a schema for our navigation and export it together with our navigationItem as types.

export const navigationSchema = z.object({
  id: z.number(),
  title: z.string(),
  items: z.array(navigationItemSchema),
});

export type NavigationItem = z.infer<typeof navigationItemSchema>;
export type Navigation = z.infer<typeof navigationSchema>;
Enter fullscreen mode Exit fullscreen mode

Processing and Fetching Navigation Data

Before utilizing the navigation data in our Astro components, we must process it to correctly assign child items to their parents. This ensures our navigation structure mirrors the setup in WordPress. Here's an outline of the processing function:


export function processNavigationItems(items: NavigationItem[]): NavigationItem[] {
  // The final processed array with top-level navigation items
  const processedItems: NavigationItem[] = [];

  items.forEach(item => {
    const currentItem = itemsMap.get(item.ID);
    if (!currentItem) {
      return; // Skip if the current item is undefined
    }

    if (item.menu_item_parent && item.menu_item_parent !== '0') {
      // If the item has a parent, add it to the parent's 'children' array
      const parentItem = itemsMap.get(parseInt(item.menu_item_parent));
      if (parentItem) {
        if (!parentItem.children) {
          parentItem.children = []; // Initialize 'children' property as an empty array if it is undefined
        }
        parentItem.children.push(currentItem);
      }
    } else {
      // If the item has no parent, it's a top-level item
      processedItems.push(currentItem);
    }
  });

  return processedItems;
}
Enter fullscreen mode Exit fullscreen mode

Following processing, we fetch the navigation data, ensuring it's ready for component integration:

function getNavigationHandler(client: AxiosInstance) {
  return async (params: GetNavigationParams): Promise<NavigationItem[]> => {
    try {
      const navigationData = await client.get<NavigationItem[]>(`menu/${params.endpoint}`);
      return navigationData.data; // Return the 'data' property of the navigationData object
    } catch (error) {
      console.error(error);
      throw error;
    }
  };
}

export async function fetchNavigation({ endpoint }: GetNavigationParams): Promise<NavigationItem[]> {
  try {
    // Create an instance of your navigation handler
    const navigationHandler = getNavigationHandler(fetchApi);
    const navigationParams: GetNavigationParams = { endpoint };
    const rawItems = await navigationHandler(navigationParams);

    // Process the raw navigation items
    return processNavigationItems(rawItems);
  } catch (error) {
    console.error('Error fetching navigation:', error);
    return [];
  }
}
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, there is a getNavigationHandler. This is how we communicate with our prior defined Interceptor, when we set up Astros API Layer.

Building Your Navigation Component in Astro

With our processed navigation data, we can now construct the navigation in our Astro frontend. The component structure involves iterating over navigation items and dynamically generating links, including handling sub-menus:

// Navigation.astro
---
import NavigationLink from "./NavigationLink.astro";
import type { NavigationItem } from "@lib/api/fetchNavigation";

type Props = {
  navigationItems: NavigationItem[];
};

const { navigationItems } = Astro.props;

---

<nav>
  <ul>
    {navigationItems.map((item) => (
      <li>
        <NavigationLink item={item} />
        {item.children && item.children.length > 0 && (
          <ul class="sub-menu">
            {item.children.map((subItem: NavigationItem) => (
              <li>
                <NavigationLink item={subItem}/>
              </li>
            ))}
          </ul>
        )}
      </li>
    ))}
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode

A complementary component, NavigationLink, is used to reduce code repetition and enhance readability:

---
const { item, className } = Astro.props;
---

<a
  href={item.url}
  aria-label={`Link to ${item.title}`}
  title={item.title}
  data-navigation-key={item.ID}
  data-astro-prefetch="hover"
  class={className}>
  {item.title}
</a>
Enter fullscreen mode Exit fullscreen mode

This approach ensures that your Astro frontend benefits from a dynamic, easily manageable navigation system powered by WordPress. I hope this guide proves useful and inspires further exploration of headless CMS integrations.

Stay tuned for the next topic, where we'll explore handling Gutenberg Blocks in Astro.

Your feedback and questions are always welcome!

Top comments (0)