Skip to content
ThemesIonic — home
WooCommerce

Editing the WooCommerce My Account Navigation

Renaming, removing and reordering entries is a one-filter job. Adding a new entry is not: the filter only prints a link, and a link to an endpoint that was never registered is a 404 the filter cannot fix.

Updated 4 min read intermediate

Reordering, renaming and removing entries is a one-filter job; adding one is three. woocommerce_account_menu_items filters the array of item ID => label that the My Account page prints as its navigation. The IDs are fixed — orders, downloads and so on — even on a store that renamed the endpoint slugs in WooCommerce › Settings › Advanced; the second argument carries the configured slugs for each. Changing that array changes the menu. It does not change what the menu points to, and that is the gap new tabs fall into.

Rename, remove, reorder

add_filter( 'woocommerce_account_menu_items', 'ti_account_menu' );

function ti_account_menu( $items ) {
	$items['orders'] = __( 'Purchases', 'ti' );

	unset( $items['downloads'] );

	$logout = $items['customer-logout'];
	unset( $items['customer-logout'] );

	$ordered = array(
		'dashboard'    => $items['dashboard'],
		'orders'       => $items['orders'],
		'edit-address' => $items['edit-address'],
	);

	foreach ( $items as $key => $label ) {
		if ( ! isset( $ordered[ $key ] ) ) {
			$ordered[ $key ] = $label;
		}
	}

	$ordered['customer-logout'] = $logout;

	return $ordered;
}

The default keys are dashboard, orders, downloads, edit-address, payment-methods, edit-account and customer-logout. Plugins add their own — subscriptions, memberships, wishlists — and the loop at the end carries any key you did not explicitly place, so a plugin tab added next month is not silently dropped. Pulling customer-logout out and appending it last keeps it where customers look for it.

Removing dashboard removes only the link. The My Account root URL still renders the dashboard template, because content and menu are resolved independently and the root falls back to the dashboard when no endpoint matches. Rename it if the label is the problem; there is no way to make the root show another tab from this filter.

Adding a tab: the three parts

A new entry needs an endpoint, content for it, and the menu item. In that order:

add_action( 'init', 'ti_register_invoices_endpoint' );

function ti_register_invoices_endpoint() {
	add_rewrite_endpoint( 'invoices', EP_ROOT | EP_PAGES );
}

add_filter( 'woocommerce_account_menu_items', 'ti_add_invoices_tab' );

function ti_add_invoices_tab( $items ) {
	$logout = $items['customer-logout'];
	unset( $items['customer-logout'] );

	$items['invoices']        = __( 'Invoices', 'ti' );
	$items['customer-logout'] = $logout;

	return $items;
}

add_action( 'woocommerce_account_invoices_endpoint', 'ti_invoices_content' );

function ti_invoices_content() {
	echo '<h3>' . esc_html__( 'Invoices', 'ti' ) . '</h3>';
	// Query and print the customer's invoices.
}

After adding the add_rewrite_endpoint call, permalinks must be flushed once — visit Settings › Permalinks and save, or call flush_rewrite_rules() on plugin activation, never on every request. Skipping this step is the 404; the menu shows the tab, the URL is /my-account/invoices/, and WordPress has never heard of it. The mechanics are the same ones behind endpoints not working, and the flush is the fix in both.

The content action's name is built from the endpoint: woocommerce_account_ + endpoint + _endpoint. A typo there produces a working tab with an empty page.

Titles and the query var

WooCommerce only treats a URL as one of its endpoints when the slug is in its own list of query vars. An endpoint registered with add_rewrite_endpoint() alone works for routing, but is_wc_endpoint_url() returns false for it, and everything gated on that — the page title filter, the endpoint-aware body classes — stays silent. Register the slug with WooCommerce first:

add_filter( 'woocommerce_get_query_vars', function ( $vars ) {
	$vars['invoices'] = 'invoices';
	return $vars;
} );

add_filter( 'woocommerce_endpoint_invoices_title', fn() => __( 'Invoices', 'ti' ) );

With the first filter in place, the second sets the browser title; without it, the title filter never fires and the page carries the My Account title.

Conditional tabs

Hiding a tab per customer is the filter again — unset inside a condition — and because the filter runs on every My Account load, the condition is evaluated per request. The usual mistake is hiding the tab and leaving the endpoint reachable by URL. Hide the content too: inside the endpoint action, check the same condition and print nothing or a notice. A menu is not access control; the capabilities system is, and user roles are where a real restriction belongs.

Verify

  • The tab appears in the menu, in the position you set, with the logout entry last.
  • Clicking it loads a page with the endpoint slug in the URL and no 404. If 404, flush permalinks.
  • The page shows your content. If empty, check the action name against the endpoint slug character by character.
  • The browser title reads your endpoint title.
  • Deactivate your plugin or remove the snippet and flush again: the tab should disappear and the URL should 404, confirming nothing was left behind in the rewrite rules.

Frequently asked

The filter only adds a menu entry. The URL it points to is an endpoint that must be registered with add_rewrite_endpoint, and permalinks must be flushed once after registering it. Until then the link exists and the address does not.
Content for an endpoint comes from the woocommerce_account_{endpoint}_endpoint action. The menu and the endpoint are wired; nothing has been told what to print. Add a callback on that action and echo the content.
Yes, with unset( $items['downloads'] ). WooCommerce does not hide it on its own — the only tab it drops automatically is Payment methods, when no gateway supports saved methods — so the filter is the way to remove it, for everyone or under a condition.

Related guides