Client-Side Navigation with the Interactivity API Router

The WordPress Interactivity API Router enables single-page application (SPA) navigation within WordPress sites, delivering instant page transitions while preserving SEO benefits, progressive enhancement, and browser history management. By intercepting link clicks and fetching content asynchronously, the router creates seamless user experiences without full page reloads.

In this article, we’ll explore how to implement client-side navigation with the @wordpress/interactivity-router package, manage navigation state, handle loading indicators, implement route-specific logic, and maintain accessibility throughout the navigation experience.

Understanding the Interactivity API Router

The router provides declarative client-side navigation by intercepting link clicks and fetching new content via AJAX, then updating specific regions of the page without full reloads.

Key Concepts

Server-Side Rendering First: The router enhances server-rendered HTML, not replaces it. Your WordPress templates render complete pages on the server, and the router progressively enhances navigation.

Region-Based Updates: Instead of replacing the entire page, the router updates specific DOM regions marked with data-wp-router-region. This allows you to maintain persistent elements like sticky headers or sidebars.

History Management: The router integrates with the browser’s History API, ensuring back/forward buttons work correctly and URLs remain shareable.

Progressive Enhancement: If JavaScript fails or is disabled, navigation falls back to standard full-page loads.

Basic Router Setup

To enable client-side navigation, add the router region directive to your template:

<?php
// header.php or template part
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo( 'charset' ); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>

<!-- Persistent header (outside router region) -->
<header class="site-header">
    <h1><?php bloginfo( 'name' ); ?></h1>
    <nav>
        <a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a>
        <a href="<?php echo esc_url( home_url( '/about' ) ); ?>">About</a>
        <a href="<?php echo esc_url( home_url( '/blog' ) ); ?>">Blog</a>
    </nav>
</header>

<!-- Router region: content that updates during navigation -->
<main data-wp-router-region="content">
    <?php
    // Your template content
    if ( have_posts() ) :
        while ( have_posts() ) :
            the_post();
            the_content();
        endwhile;
    endif;
    ?>
</main>

<!-- Persistent footer (outside router region) -->
<footer class="site-footer">
    <p>&copy; <?php echo date( 'Y' ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>

<?php wp_footer(); ?>
</body>
</html>

What Happens:

  1. User clicks a link pointing to another page on your site
  2. Router intercepts the click and prevents default navigation
  3. Router fetches the target page content via AJAX
  4. Router extracts content from the data-wp-router-region in the response
  5. Router replaces the current region content with the new content
  6. Browser URL updates without page reload

The header and footer remain unchanged, creating a seamless transition.

Implementing Navigation State

Track navigation state to provide visual feedback during page transitions:

// view.js
import { store } from '@wordpress/interactivity';

store( 'myTheme', {
    state: {
        isNavigating: false,
        currentPath: window.location.pathname,
        navigationError: null
    },

    actions: {
        *navigate() {
            const { state } = store( 'myTheme' );

            // Generator function pattern for router actions
            state.isNavigating = true;
            state.navigationError = null;

            try {
                // Navigation logic handled by router
                yield;
            } catch ( error ) {
                state.navigationError = error.message;
            } finally {
                state.isNavigating = false;
                state.currentPath = window.location.pathname;
            }
        }
    },

    callbacks: {
        onNavigationStart() {
            const { state } = store( 'myTheme' );
            state.isNavigating = true;
            console.log( 'Navigation started' );
        },

        onNavigationEnd() {
            const { state } = store( 'myTheme' );
            state.isNavigating = false;
            console.log( 'Navigation completed' );
        }
    }
} );
<?php
// Template with navigation state
?>
<div data-wp-interactive="myTheme">
    <!-- Loading indicator -->
    <div
        data-wp-class--visible="state.isNavigating"
        data-wp-class--hidden="!state.isNavigating"
        class="navigation-loading"
    >
        <div class="spinner"></div>
        <p>Loading...</p>
    </div>

    <!-- Error message -->
    <div
        data-wp-class--visible="state.navigationError"
        class="navigation-error"
    >
        <p data-wp-text="state.navigationError"></p>
    </div>

    <!-- Router region -->
    <main data-wp-router-region="content">
        <?php the_content(); ?>
    </main>
</div>

Advanced Navigation Patterns

Multiple Router Regions

Update multiple independent regions during navigation:

<?php
// Template with multiple regions
?>
<div data-wp-interactive="myTheme">
    <header class="site-header">
        <h1><?php bloginfo( 'name' ); ?></h1>

        <!-- Navigation region for breadcrumbs -->
        <nav data-wp-router-region="breadcrumbs">
            <?php
            if ( function_exists( 'yoast_breadcrumb' ) ) {
                yoast_breadcrumb( '<p id="breadcrumbs">', '</p>' );
            }
            ?>
        </nav>
    </header>

    <div class="site-content">
        <!-- Sidebar region -->
        <aside data-wp-router-region="sidebar">
            <?php dynamic_sidebar( 'primary-sidebar' ); ?>
        </aside>

        <!-- Main content region -->
        <main data-wp-router-region="content">
            <?php
            if ( have_posts() ) :
                while ( have_posts() ) :
                    the_post();
                    get_template_part( 'template-parts/content', get_post_type() );
                endwhile;
            endif;
            ?>
        </main>
    </div>
</div>

When navigating, the router updates all three regions (breadcrumbssidebarcontent) with content from the target page, preserving the header structure.

Custom Link Behaviour

Control which links trigger client-side navigation:

import { store, navigate } from '@wordpress/interactivity';

store( 'myTheme', {
    actions: {
        handleLinkClick( event ) {
            const link = event.target.closest( 'a' );

            if ( ! link ) {
                return;
            }

            // Skip external links
            if ( link.hostname !== window.location.hostname ) {
                return;
            }

            // Skip download links
            if ( link.hasAttribute( 'download' ) ) {
                return;
            }

            // Skip anchor links on same page
            if (
                link.pathname === window.location.pathname &&
                link.hash
            ) {
                return;
            }

            // Trigger client-side navigation
            event.preventDefault();
            navigate( link.href );
        }
    }
} );
<div
    data-wp-interactive="myTheme"
    data-wp-on--click="actions.handleLinkClick"
>
    <!-- Internal link: uses router -->
    <a href="/about">About Us</a>

    <!-- External link: full page load -->
    <a href="https://external-site.com">External Site</a>

    <!-- Download link: full page load -->
    <a href="/file.pdf" download>Download PDF</a>

    <!-- Anchor link: standard scroll -->
    <a href="#section">Jump to Section</a>
</div>

Programmatic Navigation

Navigate programmatically in response to user actions:

import { store, navigate } from '@wordpress/interactivity';

store( 'myPlugin', {
    actions: {
        async submitForm( event ) {
            event.preventDefault();

            const { state } = store( 'myPlugin' );
            state.isSubmitting = true;

            try {
                const response = await fetch( '/wp-json/myplugin/v1/submit', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify( state.formData )
                } );

                const result = await response.json();

                if ( result.success ) {
                    // Navigate to success page using router
                    navigate( result.redirectUrl );
                } else {
                    state.error = result.message;
                }
            } catch ( error ) {
                state.error = 'Submission failed';
            } finally {
                state.isSubmitting = false;
            }
        },

        redirectToLogin() {
            // Redirect unauthenticated users
            const { state } = store( 'myPlugin' );

            if ( ! state.isLoggedIn ) {
                navigate( '/login?redirect=' + encodeURIComponent( window.location.pathname ) );
            }
        }
    }
} );

Loading States and Transitions

Create smooth transitions with loading indicators and skeleton screens:

Progress Bar

store( 'myTheme', {
    state: {
        navigationProgress: 0
    },

    callbacks: {
        onNavigationStart() {
            const { state } = store( 'myTheme' );
            state.navigationProgress = 0;

            // Simulate progress
            const interval = setInterval( () => {
                if ( state.navigationProgress < 90 ) {
                    state.navigationProgress += 10;
                } else {
                    clearInterval( interval );
                }
            }, 100 );

            // Store interval ID for cleanup
            state.progressInterval = interval;
        },

        onNavigationEnd() {
            const { state } = store( 'myTheme' );

            // Complete progress
            state.navigationProgress = 100;

            // Cleanup
            if ( state.progressInterval ) {
                clearInterval( state.progressInterval );
            }

            // Reset after animation
            setTimeout( () => {
                state.navigationProgress = 0;
            }, 500 );
        }
    }
} );
<div data-wp-interactive="myTheme">
    <!-- Progress bar -->
    <div
        class="progress-bar"
        data-wp-class--visible="state.navigationProgress > 0"
        data-wp-style--width="state.navigationProgress + '%'"
    ></div>

    <main data-wp-router-region="content">
        <?php the_content(); ?>
    </main>
</div>

Skeleton Screens

Display placeholder content during navigation:

<div data-wp-interactive="myTheme">
    <!-- Skeleton loader (shown during navigation) -->
    <div
        class="skeleton-loader"
        data-wp-class--visible="state.isNavigating"
    >
        <div class="skeleton-title"></div>
        <div class="skeleton-text"></div>
        <div class="skeleton-text"></div>
        <div class="skeleton-image"></div>
    </div>

    <!-- Actual content (hidden during navigation) -->
    <main
        data-wp-router-region="content"
        data-wp-class--hidden="state.isNavigating"
    >
        <?php the_content(); ?>
    </main>
</div>
.skeleton-loader {
    display: none;
}

.skeleton-loader.visible {
    display: block;
}

.skeleton-title,
.skeleton-text,
.skeleton-image {
    background: linear-gradient(
        90deg,
        #f0f0f0 25%,
        #e0e0e0 50%,
        #f0f0f0 75%
    );
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
}

.skeleton-title {
    height: 40px;
    width: 60%;
    margin-bottom: 20px;
}

.skeleton-text {
    height: 16px;
    width: 100%;
    margin-bottom: 12px;
}

.skeleton-image {
    height: 200px;
    width: 100%;
}

@keyframes loading {
    0% {
        background-position: 200% 0;
    }
    100% {
        background-position: -200% 0;
    }
}

Route-Specific Logic

Execute JavaScript based on the current route:

store( 'myTheme', {
    state: {
        currentRoute: window.location.pathname,
        isHomePage: window.location.pathname === '/',
        isBlogPage: window.location.pathname.startsWith( '/blog' ),
        isProductPage: false
    },

    callbacks: {
        onNavigationEnd() {
            const { state, actions } = store( 'myTheme' );

            // Update route state
            state.currentRoute = window.location.pathname;
            state.isHomePage = window.location.pathname === '/';
            state.isBlogPage = window.location.pathname.startsWith( '/blog' );
            state.isProductPage = window.location.pathname.startsWith( '/product' );

            // Execute route-specific logic
            if ( state.isHomePage ) {
                actions.initializeHomePageFeatures();
            } else if ( state.isBlogPage ) {
                actions.loadBlogSidebar();
            } else if ( state.isProductPage ) {
                actions.initializeProductGallery();
            }

            // Track page view for analytics
            if ( typeof gtag !== 'undefined' ) {
                gtag( 'config', 'GA_MEASUREMENT_ID', {
                    page_path: state.currentRoute
                } );
            }
        },

        initializeHomePageFeatures() {
            console.log( 'Initializing home page features' );
            // Home page specific logic
        },

        loadBlogSidebar() {
            console.log( 'Loading blog sidebar' );
            // Blog page specific logic
        },

        initializeProductGallery() {
            console.log( 'Initializing product gallery' );
            // Product page specific logic
        }
    }
} );

Handling Navigation Events

The router fires events you can hook into for custom behaviour:

store( 'myTheme', {
    callbacks: {
        // Before navigation starts
        onNavigationStart() {
            const { state } = store( 'myTheme' );

            // Save scroll position
            state.scrollPosition = window.scrollY;

            // Start loading indicators
            state.isNavigating = true;

            // Pause any running animations or media
            const videos = document.querySelectorAll( 'video' );
            videos.forEach( video => video.pause() );
        },

        // After navigation completes
        onNavigationEnd() {
            const { state } = store( 'myTheme' );

            // Stop loading indicators
            state.isNavigating = false;

            // Scroll to top or restore position
            if ( window.history.state?.scrollY !== undefined ) {
                window.scrollTo( 0, window.history.state.scrollY );
            } else {
                window.scrollTo( 0, 0 );
            }

            // Re-initialize third-party libraries
            if ( typeof initializeGallery === 'function' ) {
                initializeGallery();
            }

            // Announce navigation to screen readers
            const announcement = document.querySelector( '[role="status"]' );
            if ( announcement ) {
                announcement.textContent = 'Page loaded: ' + document.title;
            }
        },

        // If navigation fails
        onNavigationError() {
            const { state } = store( 'myTheme' );

            state.isNavigating = false;
            state.navigationError = 'Failed to load page. Please try again.';

            // Optionally fall back to full page load
            window.location.reload();
        }
    }
} );

SEO and Accessibility Considerations

Title and Meta Tags

Update document title and meta tags during navigation:

store( 'myTheme', {
    callbacks: {
        onNavigationEnd() {
            // Update document title (router handles this automatically)
            // but you can add custom logic if needed

            // Update meta description
            const description = document.querySelector( 'meta[name="description"]' );
            if ( description ) {
                const newDescription = document.querySelector(
                    '[data-wp-router-region] meta[name="description"]'
                );
                if ( newDescription ) {
                    description.setAttribute( 'content', newDescription.getAttribute( 'content' ) );
                }
            }

            // Update Open Graph tags
            const ogTitle = document.querySelector( 'meta[property="og:title"]' );
            if ( ogTitle ) {
                ogTitle.setAttribute( 'content', document.title );
            }

            const ogUrl = document.querySelector( 'meta[property="og:url"]' );
            if ( ogUrl ) {
                ogUrl.setAttribute( 'content', window.location.href );
            }
        }
    }
} );

Screen Reader Announcements

Announce navigation to assistive technologies:

<div data-wp-interactive="myTheme">
    <!-- ARIA live region for announcements -->
    <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        class="sr-only"
    >
        <span data-wp-text="state.navigationAnnouncement"></span>
    </div>

    <!-- Skip to content link -->
    <a href="#main-content" class="skip-link">
        Skip to content
    </a>

    <main id="main-content" data-wp-router-region="content">
        <?php the_content(); ?>
    </main>
</div>
store( 'myTheme', {
    state: {
        navigationAnnouncement: ''
    },

    callbacks: {
        onNavigationEnd() {
            const { state } = store( 'myTheme' );

            // Announce page load
            state.navigationAnnouncement = `Navigated to ${ document.title }`;

            // Clear announcement after delay
            setTimeout( () => {
                state.navigationAnnouncement = '';
            }, 1000 );

            // Focus main content
            const mainContent = document.getElementById( 'main-content' );
            if ( mainContent ) {
                mainContent.setAttribute( 'tabindex', '-1' );
                mainContent.focus();
            }
        }
    }
} );

Focus Management

Ensure keyboard users can navigate efficiently:

store( 'myTheme', {
    callbacks: {
        onNavigationEnd() {
            // Find first heading in new content
            const mainHeading = document.querySelector(
                '[data-wp-router-region="content"] h1, [data-wp-router-region="content"] h2'
            );

            if ( mainHeading ) {
                mainHeading.setAttribute( 'tabindex', '-1' );
                mainHeading.focus();

                // Remove tabindex after focus to restore natural tab order
                mainHeading.addEventListener(
                    'blur',
                    () => mainHeading.removeAttribute( 'tabindex' ),
                    { once: true }
                );
            }
        }
    }
} );

Real-World Implementation: Blog with Client-Side Navigation

Complete example of a blog theme with router integration:

<?php
/**
 * Template: index.php
 * Blog listing page with client-side navigation
 */

// Initialize state
wp_interactivity_state( 'myBlogTheme', array(
    'isNavigating' => false,
    'currentPage' => get_query_var( 'paged', 1 ),
    'totalPages' => $wp_query->max_num_pages,
) );
?>

<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo( 'charset' ); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php wp_title(); ?></title>
    <?php wp_head(); ?>
</head>
<body <?php body_class(); ?> data-wp-interactive="myBlogTheme">

<!-- Persistent header -->
<header class="site-header">
    <h1><a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php bloginfo( 'name' ); ?></a></h1>
    <nav class="main-nav">
        <a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a>
        <a href="<?php echo esc_url( home_url( '/blog' ) ); ?>">Blog</a>
        <a href="<?php echo esc_url( home_url( '/about' ) ); ?>">About</a>
        <a href="<?php echo esc_url( home_url( '/contact' ) ); ?>">Contact</a>
    </nav>
</header>

<!-- Loading indicator -->
<div
    class="loading-bar"
    data-wp-class--active="state.isNavigating"
></div>

<!-- Router region: main content -->
<main data-wp-router-region="content" class="site-content">
    <h1><?php the_archive_title(); ?></h1>

    <?php if ( have_posts() ) : ?>
        <div class="posts-grid">
            <?php while ( have_posts() ) : the_post(); ?>
                <article class="post-card">
                    <?php if ( has_post_thumbnail() ) : ?>
                        <a href="<?php the_permalink(); ?>">
                            <?php the_post_thumbnail( 'medium' ); ?>
                        </a>
                    <?php endif; ?>

                    <h2>
                        <a href="<?php the_permalink(); ?>">
                            <?php the_title(); ?>
                        </a>
                    </h2>

                    <div class="post-meta">
                        <time datetime="<?php echo get_the_date( 'c' ); ?>">
                            <?php echo get_the_date(); ?>
                        </time>
                        <span class="author">by <?php the_author(); ?></span>
                    </div>

                    <div class="excerpt">
                        <?php the_excerpt(); ?>
                    </div>

                    <a href="<?php the_permalink(); ?>" class="read-more">
                        Read More →
                    </a>
                </article>
            <?php endwhile; ?>
        </div>

        <!-- Pagination -->
        <?php if ( $wp_query->max_num_pages > 1 ) : ?>
            <nav class="pagination" aria-label="Posts pagination">
                <?php
                echo paginate_links( array(
                    'prev_text' => '← Previous',
                    'next_text' => 'Next →',
                    'type' => 'list',
                ) );
                ?>
            </nav>
        <?php endif; ?>

    <?php else : ?>
        <p>No posts found.</p>
    <?php endif; ?>
</main>

<!-- Persistent sidebar -->
<aside data-wp-router-region="sidebar" class="site-sidebar">
    <?php dynamic_sidebar( 'blog-sidebar' ); ?>
</aside>

<!-- Persistent footer -->
<footer class="site-footer">
    <p>&copy; <?php echo date( 'Y' ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>

<?php wp_footer(); ?>
</body>
</html>
// view.js
import { store } from '@wordpress/interactivity';

store( 'myBlogTheme', {
    state: {
        isNavigating: false,
        currentPage: 1,
        totalPages: 1
    },

    callbacks: {
        onNavigationStart() {
            const { state } = store( 'myBlogTheme' );
            state.isNavigating = true;

            // Save scroll position
            window.history.replaceState(
                { ...window.history.state, scrollY: window.scrollY },
                ''
            );
        },

        onNavigationEnd() {
            const { state } = store( 'myBlogTheme' );
            state.isNavigating = false;

            // Update page number from URL
            const urlParams = new URLSearchParams( window.location.search );
            state.currentPage = parseInt( urlParams.get( 'paged' ) || '1', 10 );

            // Scroll to top of content
            const mainContent = document.querySelector( '[data-wp-router-region="content"]' );
            if ( mainContent ) {
                mainContent.scrollIntoView( { behavior: 'smooth' } );
            }

            // Track page view
            if ( typeof gtag !== 'undefined' ) {
                gtag( 'config', 'GA_MEASUREMENT_ID', {
                    page_path: window.location.pathname
                } );
            }

            // Re-initialize any third-party scripts
            if ( typeof initializeLightbox === 'function' ) {
                initializeLightbox();
            }
        }
    }
} );

This implementation provides:

  • Instant navigation between blog posts and archive pages
  • Persistent header/footer that doesn’t re-render
  • Loading indicator for visual feedback
  • Pagination that works with router
  • Dynamic sidebar updates per page
  • Analytics tracking for SPA navigation
  • Scroll management with smooth scrolling
  • Progressive enhancement with fallback to standard navigation

Conclusion

The Interactivity API Router brings single-page application navigation to WordPress, enabling instant page transitions while preserving WordPress’s server-rendering benefits, SEO advantages, and accessibility features.

Key Takeaways:

  • Use data-wp-router-region to mark updatable content areas
  • Track navigation state for loading indicators and transitions
  • Handle navigation events for analytics, focus management, and custom logic
  • Maintain accessibility with ARIA announcements and focus management
  • Update meta tags and document title for SEO
  • Implement progressive enhancement for JavaScript-disabled scenarios
Wp block editor book nobg

A comprehensive guide


Master WordPress Block Development

Everything you need to master blocks in one place, from the first block to enterprise architecture.

60-Day 100% Money-Back Guarantee. Zero risk.