Skip to content
ThemesIonic — home
WordPress Tutorials

Adding Dynamic Items to a WordPress Menu

The filter hands you the menu as a finished HTML string, which is why it is quick for a login link and useless for anything structural. It also never fires for the Navigation block, which is most menus on block themes.

Updated 4 min read intermediate

The filter sees the menu after it has become HTML. wp_nav_menu_items runs inside wp_nav_menu() with the complete <li>…</li> string for the menu and the arguments the call was made with. Appending a string is trivial; anything else is editing markup you do not control. And on a block theme, where menus are rendered by the Navigation block rather than wp_nav_menu(), the filter never runs at all.

add_filter( 'wp_nav_menu_items', 'ti_login_menu_item', 10, 2 );

function ti_login_menu_item( $items, $args ) {
	if ( 'primary' !== $args->theme_location ) {
		return $items;
	}

	if ( is_user_logged_in() ) {
		$link = '<a href="' . esc_url( wp_logout_url( home_url( '/' ) ) ) . '">' . esc_html__( 'Log out', 'ti' ) . '</a>';
	} else {
		$link = '<a href="' . esc_url( wp_login_url( home_url( add_query_arg( array() ) ) ) ) . '">' . esc_html__( 'Log in', 'ti' ) . '</a>';
	}

	return $items . '<li class="menu-item ti-menu-login">' . $link . '</li>';
}

The location check is the difference between one link in the header and the same link in the footer, the mobile menu and the sidebar widget. Theme locations are registered by the theme with register_nav_menus(), and the slug is whatever the theme chose — primary, main, header, menu-1. Find it in the theme's functions.php or in Appearance › Menus under Display location.

The menu-item class matters more than it looks. Themes style li.menu-item, and a bare <li> without it inherits none of the spacing, hover state or mobile behaviour of its neighbours.

Why it does nothing on a block theme

The Navigation block renders from block markup and page-list or menu data, through its own render callback. wp_nav_menu() is not called, so the filter has nothing to run on. The same is true for a classic theme that prints a menu by iterating wp_get_nav_menu_items() itself, which some page-builder headers do.

On a block theme the equivalent is editing the Navigation block in the Site Editor, where a Login/out block exists for exactly this case. A dynamic item that has no block needs a small custom block declaring core/navigation as its parent so it can be inserted there — more work, but it survives template edits, which a filtered string never had to. The navigation block guide covers what the block can do without code.

Position and structure

Because $items is a string, you can prepend, append, or run a str_replace() against it. The third option is fragile: the markup changes between WordPress versions, between themes using custom walkers, and whenever a menu item gains a class. For an item at a specific position, or inside a dropdown, filter the item objects instead:

add_filter( 'wp_nav_menu_objects', 'ti_insert_menu_object', 10, 2 );

function ti_insert_menu_object( $items, $args ) {
	if ( 'primary' !== $args->theme_location ) {
		return $items;
	}

	$item = (object) array(
		'ID'                   => 999999,
		'db_id'                => 999999,
		'menu_item_parent'     => 0,
		'post_parent'          => 0,
		'object_id'            => 0,
		'object'               => 'custom',
		'type'                 => 'custom',
		'type_label'           => __( 'Custom Link' ),
		'title'                => __( 'Pricing', 'ti' ),
		'url'                  => home_url( '/pricing/' ),
		'target'               => '',
		'attr_title'           => '',
		'description'          => '',
		'xfn'                  => '',
		'classes'              => array( 'menu-item', 'ti-menu-pricing' ),
		'menu_order'           => 2,
		'current'              => false,
		'current_item_ancestor' => false,
		'current_item_parent'  => false,
	);

	array_splice( $items, 1, 0, array( $item ) );

	return $items;
}

The long property list is not optional: the walker and the current-item logic read current, target, xfn and the rest without checking they exist, and a bare object with only a title and URL raises an undefined-property warning on every page view under PHP 8. wp_setup_nav_menu_item() fills the same properties from a post object if you prefer. With them set, the walker renders the item like any other, including inside the theme's dropdown and mobile markup. It is the right tool when the item has to sit between existing entries or under a parent, as in a dropdown menu.

Caching and the login state

A page cache serves the same HTML to everyone, including the menu. A login link rendered for a guest is served to a logged-in visitor from cache, and a logout link is served to guests. Most cache plugins bypass the cache for logged-in users by default, which is what makes this pattern work; disabling that bypass for performance reasons breaks it. If the wrong link shows for the wrong state, check that first — it is the same mechanism behind changes not showing after any menu edit.

Verify

  • Log out: the menu shows Log in, in the location you targeted and nowhere else.
  • Log in: it shows Log out, and following it returns to the home page.
  • Inspect the item: it has the menu-item class and matches its neighbours' styling.
  • Switch the theme to a block theme on a staging copy: the item vanishes, which confirms the filter's limit before a redesign discovers it.

Frequently asked

Block themes build menus with the Navigation block, which does not call wp_nav_menu(). The filter runs only inside that function. On a block theme, add the item in the Site Editor, or use a custom block inside the Navigation block.
Check $args->theme_location against the location registered by the theme, such as 'primary'. The second argument carries every argument passed to wp_nav_menu(), so the location, the menu ID and the container are all available.
Only by string manipulation on the HTML, which breaks the first time the markup changes. For positional control use the wp_nav_menu_objects filter, which gives you the array of item objects before they are rendered.

Related guides