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>© <?php echo date( 'Y' ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>What Happens:
- User clicks a link pointing to another page on your site
- Router intercepts the click and prevents default navigation
- Router fetches the target page content via AJAX
- Router extracts content from the
data-wp-router-regionin the response - Router replaces the current region content with the new content
- 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 (breadcrumbs, sidebar, content) 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>© <?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-regionto 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
