Build a Custom WordPress Navigation Menu Walker for Bootstrap 5

WordPress renders navigation menus by walking a tree of menu items and outputting HTML. The default output — a nested <ul> and <li> structure — works with many CSS frameworks, but modern design systems frequently need specific markup: Bootstrap 5 requires dropdown-menu classes and data-bs-toggle attributes, custom mega-menus need wrapper divs around sub-menus, and accessible hamburger patterns sometimes need the toggle button inside the <li> rather than outside. WordPress’s Walker_Nav_Menu class controls every piece of this HTML. By extending it and overriding one or more of its four methods — start_lvl(), end_lvl(), start_el(), and end_el() — you can produce any markup structure without touching the core walker or using regex on the output string. This article builds a Bootstrap 5-compatible walker as a practical example, then shows how to register it with wp_nav_menu().

Problem: The default WordPress menu walker outputs plain <ul><li> markup that does not match your CSS framework's required structure — for example Bootstrap 5 dropdowns need specific class names and data-bs-toggle attributes.

Solution: Extend Walker_Nav_Menu and override start_el() and start_lvl() to output the markup your framework requires. Pass the custom walker to wp_nav_menu() via the 'walker' argument.

<?php
class Bootstrap5_Walker_Nav_Menu extends Walker_Nav_Menu {

    // Opening <ul> for a sub-menu level
    public function start_lvl( &$output, $depth = 0, $args = null ) {
        $output .= '<ul class="dropdown-menu">';
    }

    // Each menu item <li> and its <a> or <button>
    public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) {
        $has_children = in_array( 'menu-item-has-children', $item->classes );
        $active_class = in_array( 'current-menu-item', $item->classes ) ? ' active' : '';
        $li_class     = $has_children ? 'nav-item dropdown' : 'nav-item';

        $output .= '<li class="' . esc_attr( $li_class ) . '">';

        if ( $has_children && $depth === 0 ) {
            // Top-level parent: render as a dropdown toggle button
            $output .= '<a class="nav-link dropdown-toggle' . esc_attr( $active_class ) . '"'
                     . ' href="' . esc_url( $item->url ) . '"'
                     . ' data-bs-toggle="dropdown"'
                     . ' aria-expanded="false">'
                     . esc_html( $item->title )
                     . '</a>';
        } elseif ( $depth > 0 ) {
            // Dropdown child item
            $output .= '<a class="dropdown-item' . esc_attr( $active_class ) . '"'
                     . ' href="' . esc_url( $item->url ) . '">'
                     . esc_html( $item->title )
                     . '</a>';
        } else {
            // Regular top-level item
            $output .= '<a class="nav-link' . esc_attr( $active_class ) . '"'
                     . ' href="' . esc_url( $item->url ) . '">'
                     . esc_html( $item->title )
                     . '</a>';
        }
    }
}

// Usage in a template:
wp_nav_menu( [
    'theme_location' => 'primary',
    'container'      => false,
    'menu_class'     => 'navbar-nav me-auto',
    'walker'         => new Bootstrap5_Walker_Nav_Menu(),
] );

NOTE: This walker strips the default WordPress ID and class attributes from <li> elements to keep the HTML clean. If you need to keep them — for example to support JavaScript that targets specific menu items — override start_el() by calling parent::start_el(...) first and then modifying the $output string. Alternatively, add a nav_menu_css_class filter to append classes to the default output rather than replacing the walker entirely.