How I Built a Smart Destination Navigation Plugin for WordPress Travel Websites

When building travel websites in WordPress, one challenge appears again and again:

How do you help visitors quickly navigate between related destination pages?

For example, imagine a destination page like Melbourne.
Under it, you may have dozens of related pages such as:

  • Attractions
  • Hotels
  • Tours
  • Activities
  • Restaurants
  • Things To Do
  • Travel Tips
  • Transportation Guides

As the site grows, visitors can easily get lost moving between these related pages.

To solve this, I recently built a lightweight custom WordPress plugin called
Destination Section Nav — a contextual navigation system designed specifically for travel and destination websites.

In this article, I’ll explain the concept, features, architecture, and why this tiny plugin became incredibly useful for large content-driven travel sites.

The Problem with Traditional Navigation

Most travel websites rely on:

  • Main menus
  • Mega menus
  • Breadcrumbs
  • Sidebar widgets

While these are useful, they often fail when a destination has a large number of deeply related pages.

For example:

Melbourne
├── Attractions
├── Hotels
├── Tours
├── Activities
├── Restaurants
├── Nightlife
├── Museums
└── Travel Tips

Once a user lands on a child page like Melbourne Attractions, they should still be able to quickly access:

  • Hotels
  • Tours
  • Activities
  • Restaurants

without going back to the homepage or hunting through menus.

That’s where contextual destination navigation becomes powerful.

The Idea Behind the Plugin

The plugin creates a smart left-side navigation menu for destination-related pages.

The navigation automatically understands relationships between:

  • Parent pages
  • Child pages
  • Related content sections

The best part is:

The same navigation follows the user throughout the entire destination section.

So whether someone is on:

  • Melbourne
  • Melbourne Attractions
  • Melbourne Hotels
  • Melbourne Tours

…the navigation remains consistent.

This dramatically improves:

  • User experience
  • Internal linking
  • Page discovery
  • Session duration
  • SEO crawlability

Core Features of the Plugin

1. Automatic Child Page Navigation

The plugin can automatically display all child pages of a parent destination page.

Example:

Parent Page

Melbourne

Child Pages

  • Attractions
  • Hotels
  • Tours
  • Activities

The plugin automatically generates the menu from these child pages.

No manual menu creation required.

2. Manual Navigation Control

Sometimes automatic navigation is not enough.

For example, you may want to:

  • Hide certain child pages
  • Reorder menu items
  • Only show specific sections

To solve this, the plugin includes a manual navigation mode.

Users can:

  • Enable/disable pages
  • Choose which pages appear
  • Customize ordering

This gives editors complete flexibility.

3. Custom Display Labels

One of the most useful features is the ability to override page titles.

Example:

Original Page Title

Best Luxury Hotels In Melbourne Australia

Navigation Label

Luxury Hotels

This keeps the navigation:

  • Clean
  • Short
  • User-friendly
  • Mobile-friendly

without changing the actual SEO-focused page title.

4. Drag & Drop Sorting

The plugin also includes drag-and-drop ordering inside the WordPress admin.

Editors can simply:

  • Grab menu items
  • Rearrange them visually
  • Save changes

No coding or menu management needed.

This improves workflow significantly for content teams.

5. Pagination for Large Destination Sections

Some destinations may contain:

  • 50+ child pages
  • 100+ child pages
  • or even 500+ child pages

Loading all pages at once inside the admin can become messy.

To solve this, the plugin includes:

  • 10 child pages per admin screen
  • Built-in pagination
  • Faster editing experience

This keeps the UI clean and scalable.

6. Elementor Compatibility

The frontend websites are built using Elementor.

Instead of creating a custom Elementor widget immediately, the plugin uses a simple shortcode:

[destination_nav]

This shortcode can be placed anywhere:

  • Elementor templates
  • Sidebar areas
  • Gutenberg blocks
  • Theme templates

The navigation automatically appears contextually for the current destination section.

How the Plugin Works Internally

The architecture is intentionally lightweight.

Root Page Detection

When a visitor lands on a page, the plugin:

  1. Detects the current page
  2. Finds the top-level ancestor (root page)
  3. Loads navigation settings from that root page

Example:

Melbourne → Attractions

The plugin detects:

Root = Melbourne

and loads Melbourne’s navigation everywhere inside that section.

Meta Box System

A custom WordPress meta box allows editors to configure navigation settings directly inside the page editor.

The meta box includes:

  • Navigation mode
  • Child page selection
  • Custom labels
  • Drag-and-drop sorting
  • Pagination

Importantly, the meta box was positioned:

Below the WordPress content editor instead of the sidebar

This improves usability significantly because editors have more space to manage large navigation structures.

Why This Approach Is Better Than WordPress Menus

Traditional WordPress menus are excellent for global navigation.

However, for destination-specific navigation they become difficult to manage because:

  • Menus grow too large
  • Editors must manually update links
  • Child pages are disconnected from hierarchy
  • Internal linking becomes inconsistent

This plugin solves that by using the actual page hierarchy as the source of truth.

Benefits include:

  • Cleaner structure
  • Better scalability
  • Easier management
  • Automatic synchronization
  • Improved SEO architecture

SEO Benefits

This type of contextual navigation has major SEO advantages.

Better Internal Linking

Each destination section becomes tightly interlinked.

Example:

Melbourne Hotels
↔ Melbourne Tours
↔ Melbourne Attractions
↔ Melbourne Restaurants

Search engines can better understand topical relationships.

Improved Crawl Depth

Important pages become easier for search engines to discover.

This reduces orphan pages and improves crawl efficiency.

Better User Signals

Visitors can navigate naturally between related pages, increasing:

  • Time on site
  • Pages per session
  • Engagement

All of which indirectly help SEO performance.

Potential Future Improvements

The plugin is intentionally lightweight right now, but there are many future possibilities:

Custom Post Type Support

Support for:

  • Tours
  • Activities
  • Hotels
  • Restaurants
  • Travel Deals

instead of only pages/posts.

AJAX Search Inside Navigation

For destinations with hundreds of child pages.

Example:

Search Melbourne Sections...

Accordion Navigation

Expandable category groups like:

Hotels
├── Luxury Hotels
├── Budget Hotels
└── Boutique Hotels

Elementor Native Widget

A fully custom Elementor widget with:

  • Style controls
  • Icons
  • Typography controls
  • Mobile layouts

Final Thoughts

Sometimes the best WordPress plugins are not giant SaaS products.

Sometimes they are:

  • Small
  • Focused
  • Lightweight
  • Practical

This destination navigation plugin solved a very real problem for travel websites:

Helping users explore destination content naturally and efficiently.

It improved:

  • User experience
  • Content discovery
  • Site architecture
  • SEO
  • Editorial workflow

…all with a relatively small amount of code.

And honestly, those are often the most rewarding projects to build.

Website

You can explore more WordPress, Elementor, WooCommerce, and SaaS development articles on:


https://syedzeeshanali.com

				
					<?php
/**
 * Plugin Name: Destination Section Nav
 * Description: Simple contextual navigation for parent pages/posts and their children (ideal for travel destination inner pages).
 * Version:     1.1.0
 * Author:      Syed Zeeshan Ali
 * Text Domain: destination-section-nav
 */

declare(strict_types=1);

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class DSN_Plugin {

	/**
	 * Meta key to store nav config.
	 */
	const META_KEY = '_dsn_nav_config';

	/**
	 * Bootstrap.
	 */
	public static function init() : void {
		// Meta box.
		add_action( 'add_meta_boxes', [ __CLASS__, 'register_meta_box' ] );
		add_action( 'save_post',      [ __CLASS__, 'save_meta_box' ], 10, 2 );

		// Shortcode.
		add_shortcode( 'destination_nav', [ __CLASS__, 'shortcode_nav' ] );

		// Frontend styles.
		add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_styles' ] );

		// Admin assets for drag & drop in meta box.
		add_action( 'admin_enqueue_scripts', [ __CLASS__, 'admin_enqueue_scripts' ] );
	}

	/**
	 * Register meta box on pages and posts.
	 * Now in the main content area (under the editor), not in the sidebar.
	 */
	public static function register_meta_box() : void {
		$post_types = apply_filters( 'dsn_nav_post_types', [ 'page', 'post' ] );

		foreach ( $post_types as $post_type ) {
			add_meta_box(
				'dsn_nav_meta',
				__( 'Destination Section Navigation', 'destination-section-nav' ),
				[ __CLASS__, 'render_meta_box' ],
				$post_type,
				'normal',   // main column
				'default'   // after editor
			);
		}
	}

	/**
	 * Render meta box UI.
	 *
	 * @param \WP_Post $post Post object.
	 */
	public static function render_meta_box( \WP_Post $post ) : void {
		wp_nonce_field( 'dsn_nav_meta_save', 'dsn_nav_meta_nonce' );

		$config = get_post_meta( $post->ID, self::META_KEY, true );
		if ( ! is_array( $config ) ) {
			$config = [
				'mode'  => 'auto', // auto|manual|disabled
				'items' => [],
			];
		}

		$mode        = $config['mode'] ?? 'auto';
		$config_items = is_array( $config['items'] ?? null ) ? $config['items'] : [];

		// Get children of this post (direct children).
		$children = get_children( [
			'post_parent' => $post->ID,
			'post_type'   => $post->post_type,
			'post_status' => 'publish',
			'orderby'     => 'menu_order title',
			'order'       => 'ASC',
		] );

		$children = $children ? array_values( $children ) : [];
		$total    = count( $children );
		$per_page = 10;

		// Current page for pagination in the meta box.
		$current_page = isset( $_GET['dsn_child_page'] )
			? max( 1, intval( $_GET['dsn_child_page'] ) )
			: 1;

		$total_pages = max( 1, (int) ceil( $total / $per_page ) );
		if ( $current_page > $total_pages ) {
			$current_page = $total_pages;
		}

		$offset          = ( $current_page - 1 ) * $per_page;
		$children_paged  = array_slice( $children, $offset, $per_page );
		?>
		<p>
			<strong><?php esc_html_e( 'Navigation Mode', 'destination-section-nav' ); ?></strong>
		</p>

		<p>
			<label>
				<input type="radio" name="dsn_nav_mode" value="auto" <?php checked( $mode, 'auto' ); ?> />
				<?php esc_html_e( 'Automatic – show all direct children (custom labels/order still apply).', 'destination-section-nav' ); ?>
			</label><br/>
			<label>
				<input type="radio" name="dsn_nav_mode" value="manual" <?php checked( $mode, 'manual' ); ?> />
				<?php esc_html_e( 'Manual – choose specific child pages and order.', 'destination-section-nav' ); ?>
			</label><br/>
			<label>
				<input type="radio" name="dsn_nav_mode" value="disabled" <?php checked( $mode, 'disabled' ); ?> />
				<?php esc_html_e( 'Disabled – no navigation for this root.', 'destination-section-nav' ); ?>
			</label>
		</p>

		<hr/>

		<p>
			<strong><?php esc_html_e( 'Child pages navigation', 'destination-section-nav' ); ?></strong><br/>
			<small>
				<?php esc_html_e( 'You can set a custom display label and drag & drop to change the navigation order.', 'destination-section-nav' ); ?>
			</small>
		</p>

		<?php if ( ! empty( $children ) ) : ?>

			<table class="widefat striped dsn-nav-children-table" id="dsn-nav-children-table">
				<thead>
					<tr>
						<th style="width:24px;"></th>
						<th><?php esc_html_e( 'Child Page', 'destination-section-nav' ); ?></th>
						<th><?php esc_html_e( 'Display Label (optional)', 'destination-section-nav' ); ?></th>
					</tr>
				</thead>
				<tbody>
				<?php
				foreach ( $children_paged as $index => $child ) :
					$child_id     = $child->ID;
					$item_config  = $config_items[ $child_id ] ?? [];
					$enabled      = isset( $item_config['enabled'] ) ? (bool) $item_config['enabled'] : true;
					$label        = isset( $item_config['label'] ) ? (string) $item_config['label'] : '';
					$order        = isset( $item_config['order'] ) ? (int) $item_config['order'] : 0;
					if ( $order <= 0 ) {
						// Default order for new items (relative to their position).
						$order = $offset + $index + 1;
					}
					?>
					<tr class="dsn-nav-child-row" data-child-id="<?php echo esc_attr( (string) $child_id ); ?>">
						<td class="dsn-nav-sort-handle" style="cursor:move;">&#x2630;</td>
						<td>
							<label>
								<input type="checkbox"
									name="dsn_nav_items[<?php echo esc_attr( (string) $child_id ); ?>][enabled]"
									value="1"
									<?php checked( $enabled ); ?>
								/>
								<?php echo esc_html( get_the_title( $child ) ); ?>
							</label>
							<input type="hidden"
								name="dsn_nav_items[<?php echo esc_attr( (string) $child_id ); ?>][order]"
								class="dsn-nav-order-input"
								value="<?php echo esc_attr( (string) $order ); ?>"
							/>
						</td>
						<td>
							<input type="text"
								class="regular-text"
								name="dsn_nav_items[<?php echo esc_attr( (string) $child_id ); ?>][label]"
								value="<?php echo esc_attr( $label ); ?>"
								placeholder="<?php esc_attr_e( 'Leave empty to use page title', 'destination-section-nav' ); ?>"
							/>
						</td>
					</tr>
				<?php endforeach; ?>
				</tbody>
			</table>

			<?php if ( $total_pages > 1 ) : ?>
				<p class="dsn-nav-pagination">
					<?php
					$base_url = remove_query_arg( [ 'dsn_child_page' ] );
					for ( $i = 1; $i <= $total_pages; $i++ ) :
						if ( $i === $current_page ) :
							?>
							<span class="dsn-nav-page current"><?php echo esc_html( (string) $i ); ?></span>
							<?php
						else :
							$url = add_query_arg(
								[
									'dsn_child_page' => $i,
								],
								$base_url
							);
							?>
							<a class="dsn-nav-page" href="<?php echo esc_url( $url . '#dsn_nav_meta' ); ?>">
								<?php echo esc_html( (string) $i ); ?>
							</a>
							<?php
						endif;
					endfor;
					?>
				</p>
			<?php endif; ?>

		<?php else : ?>
			<p>
				<em><?php esc_html_e( 'This post currently has no child pages. Automatic and manual modes will have nothing to show.', 'destination-section-nav' ); ?></em>
			</p>
		<?php
		endif;
	}

	/**
	 * Save meta box.
	 *
	 * @param int      $post_id Post ID.
	 * @param \WP_Post $post    Post object.
	 */
	public static function save_meta_box( int $post_id, \WP_Post $post ) : void {
		// Check nonce.
		if ( ! isset( $_POST['dsn_nav_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dsn_nav_meta_nonce'] ) ), 'dsn_nav_meta_save' ) ) {
			return;
		}

		// Autosave?
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		// Proper capability check.
		if ( 'page' === $post->post_type ) {
			if ( ! current_user_can( 'edit_page', $post_id ) ) {
				return;
			}
		} else {
			if ( ! current_user_can( 'edit_post', $post_id ) ) {
				return;
			}
		}

		$mode = isset( $_POST['dsn_nav_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['dsn_nav_mode'] ) ) : 'auto';
		if ( ! in_array( $mode, [ 'auto', 'manual', 'disabled' ], true ) ) {
			$mode = 'auto';
		}

		// Load existing config to merge, so pagination does not wipe previous pages.
		$existing = get_post_meta( $post_id, self::META_KEY, true );
		if ( ! is_array( $existing ) ) {
			$existing = [
				'mode'  => 'auto',
				'items' => [],
			];
		}
		$existing_items = is_array( $existing['items'] ?? null ) ? $existing['items'] : [];

		$items_raw = isset( $_POST['dsn_nav_items'] ) && is_array( $_POST['dsn_nav_items'] )
			? $_POST['dsn_nav_items']
			: [];

		foreach ( $items_raw as $child_id_str => $row ) {
			$child_id = absint( $child_id_str );
			if ( $child_id <= 0 ) {
				continue;
			}

			$row = is_array( $row ) ? $row : [];

			$enabled = ! empty( $row['enabled'] );
			$label   = isset( $row['label'] ) ? sanitize_text_field( wp_unslash( $row['label'] ) ) : '';
			$order   = isset( $row['order'] ) ? (int) $row['order'] : 0;

			$existing_items[ $child_id ] = [
				'enabled' => $enabled,
				'label'   => $label,
				'order'   => $order,
			];
		}

		$config = [
			'mode'  => $mode,
			'items' => $existing_items,
		];

		update_post_meta( $post_id, self::META_KEY, $config );
	}

	/**
	 * Shortcode callback: [destination_nav]
	 *
	 * @param array $atts Shortcode attributes (unused for now).
	 * @return string
	 */
	public static function shortcode_nav( array $atts ) : string {
		if ( ! is_singular() ) {
			return '';
		}

		global $post;

		if ( ! $post instanceof \WP_Post ) {
			return '';
		}

		$root_id = self::get_root_post_id( $post->ID );
		if ( ! $root_id ) {
			return '';
		}

		$config = get_post_meta( $root_id, self::META_KEY, true );
		if ( ! is_array( $config ) ) {
			$config = [
				'mode'  => 'auto',
				'items' => [],
			];
		}

		$mode         = $config['mode'] ?? 'auto';
		$config_items = is_array( $config['items'] ?? null ) ? $config['items'] : [];

		if ( 'disabled' === $mode ) {
			return '';
		}

		// All direct children of the root.
		$children = get_children( [
			'post_parent' => $root_id,
			'post_status' => 'publish',
			'orderby'     => 'menu_order title',
			'order'       => 'ASC',
		] );

		if ( empty( $children ) ) {
			return '';
		}

		$children = array_values( $children );
		$items    = [];

		foreach ( $children as $index => $child ) {
			$child_id    = $child->ID;
			$item_config = $config_items[ $child_id ] ?? [];

			$enabled = isset( $item_config['enabled'] ) ? (bool) $item_config['enabled'] : true;
			$label   = isset( $item_config['label'] ) ? (string) $item_config['label'] : '';
			$order   = isset( $item_config['order'] ) ? (int) $item_config['order'] : 0;

			if ( $order <= 0 ) {
				$order = $index + 1;
			}

			// In manual mode we respect the checkbox; in auto mode we include all.
			if ( 'manual' === $mode && ! $enabled ) {
				continue;
			}

			$items[] = [
				'post'  => $child,
				'label' => $label,
				'order' => $order,
			];
		}

		if ( empty( $items ) ) {
			return '';
		}

		// Sort by custom order.
		usort(
			$items,
			static function ( array $a, array $b ) : int {
				$ao = $a['order'];
				$bo = $b['order'];

				if ( $ao === $bo ) {
					return 0;
				}

				return ( $ao < $bo ) ? -1 : 1;
			}
		);

		$current_id = $post->ID;
		$root_title = get_the_title( $root_id );

		ob_start();
		?>
		<nav class="dsn-nav" aria-label="<?php echo esc_attr( $root_title ); ?>">
			<ul class="dsn-nav__list">
				<?php foreach ( $items as $item ) : ?>
					<?php
					$item_post   = $item['post'];
					$item_id     = $item_post->ID;
					$is_current  = ( $item_id === $current_id );
					$classes     = [ 'dsn-nav__item' ];
					if ( $is_current ) {
						$classes[] = 'dsn-nav__item--current';
					}
					$text = '' !== $item['label']
						? $item['label']
						: get_the_title( $item_post );
					?>
					<li class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>">
						<a href="<?php echo esc_url( get_permalink( $item_post ) ); ?>" class="dsn-nav__link">
							<?php echo esc_html( $text ); ?>
						</a>
					</li>
				<?php endforeach; ?>
			</ul>
		</nav>
		<?php

		return (string) ob_get_clean();
	}

	/**
	 * Find the "root" post for a given post ID (top-most ancestor).
	 *
	 * @param int $post_id Post ID.
	 * @return int Root post ID.
	 */
	private static function get_root_post_id( int $post_id ) : int {
		$ancestors = get_post_ancestors( $post_id );
		if ( ! empty( $ancestors ) ) {
			// Ancestors are ordered from closest parent to furthest.
			return (int) end( $ancestors );
		}

		return $post_id;
	}

	/**
	 * Enqueue minimal CSS (frontend).
	 */
	public static function enqueue_styles() : void {
		$css = '
		.dsn-nav {
			margin: 0 0 1.5em;
		}
		.dsn-nav__list {
			list-style: none;
			margin: 0;
			padding: 0;
		}
		.dsn-nav__item {
			margin: 0;
		}
		.dsn-nav__link {
			display: block;
			padding: 0.4em 0.6em;
			text-decoration: none;
		}
		.dsn-nav__item--current > .dsn-nav__link {
			font-weight: 600;
			text-decoration: underline;
		}';

		wp_register_style( 'destination-section-nav', false );
		wp_enqueue_style( 'destination-section-nav' );
		wp_add_inline_style( 'destination-section-nav', $css );
	}

	/**
	 * Admin assets: enable drag & drop sorting for the child list.
	 */
	public static function admin_enqueue_scripts( string $hook_suffix ) : void {
		if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) {
			return;
		}

		$screen = get_current_screen();
		if ( ! $screen ) {
			return;
		}

		$post_types = apply_filters( 'dsn_nav_post_types', [ 'page', 'post' ] );
		if ( ! in_array( $screen->post_type, $post_types, true ) ) {
			return;
		}

		// Drag & drop.
		wp_enqueue_script( 'jquery-ui-sortable' );

		$inline_js = '
		jQuery(function($){
			var $table = $("#dsn-nav-children-table");
			if (!$table.length) {
				return;
			}
			var $tbody = $table.find("tbody");
			$tbody.sortable({
				handle: ".dsn-nav-sort-handle",
				items: "> tr",
				update: function(){
					$tbody.find("tr").each(function(index){
						$(this).find(".dsn-nav-order-input").val(index + 1);
					});
				}
			});
		});
		';

		wp_add_inline_script( 'jquery-ui-sortable', $inline_js );

		$inline_css = '
		#dsn-nav-children-table .dsn-nav-sort-handle {
			text-align: center;
			width: 24px;
		}
		.dsn-nav-pagination {
			margin-top: 8px;
		}
		.dsn-nav-pagination .dsn-nav-page {
			display: inline-block;
			margin-right: 4px;
			padding: 2px 6px;
			text-decoration: none;
		}
		.dsn-nav-pagination .dsn-nav-page.current {
			font-weight: 600;
			text-decoration: underline;
		}
		';

		wp_register_style( 'dsn-nav-admin', false );
		wp_enqueue_style( 'dsn-nav-admin' );
		wp_add_inline_style( 'dsn-nav-admin', $inline_css );
	}
}

DSN_Plugin::init();
				
			

Need Help?

If you need help customizing this further (ribbon style, animated badges, dynamic positioning),

feel free to reach out — I’d be happy to help!

Leave a Reply

Your email address will not be published. Required fields are marked *

Are you human? Please solve:Captcha