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:
- Detects the current page
- Finds the top-level ancestor (root page)
- 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:
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 );
?>
$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;
}
?>
☰
1 ) : ?>
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();
?>
.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!