Next.js course ยท Module 2: Routing and Layouts
Shallow Routing - Navigation Without Page Reload
In this lesson9
In the Quantum Metropolis, transport engineers developed a special technology that allows modifying a vehicle's route without the need to stop and restart it. This innovation, called "Shallow Trajectory Change", allows dynamic modification of trip parameters without interrupting the passenger's experience - no stops, no delays, everything happens smoothly while in motion.
In Next.js 16, there is a similar concept called "Shallow Routing". This is a powerful technique that allows updating the page URL without a full page reload, while preserving component state. It is particularly useful in applications that require dynamically changing query parameters without losing component state.
What is Shallow Routing?
Shallow routing in Next.js lets you change the URL without:
- fetching the page from the server again (Server Components and
generateMetadatadon't run again) - a full page reload
- losing the state of client components
Components that read useSearchParams or usePathname re-render with the new values - that's the point.
It is an ideal solution for filters, sorting, pagination, and other scenarios where we want to preserve the user context while updating the URL for browser history or sharing purposes.
How to Use Shallow Routing in Next.js 16?
In the App Router in Next.js 16, you implement shallow routing with the browser's native window.history.pushState and window.history.replaceState methods. Next.js (since version 14.1) integrates them into its router, so the useSearchParams and usePathname hooks see the new address right away, and the page is not fetched from the server again. The router.push method from the 'next/navigation' package has no shallow option: it only accepts scroll and always performs a regular navigation.
Let's see how this looks in practice:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function QuantumFilterPanel() {
const router = useRouter();
const searchParams = useSearchParams();
// Function to update URL parameters while preserving component state
const updateFilter = useCallback((name: string, value: string) => {
// Creating a new URLSearchParams object based on current parameters
const params = new URLSearchParams(searchParams.toString());
// Update or add a new parameter
params.set(name, value);
// Update URL without a full page reload
window.history.pushState(null, '', '?' + params.toString()); // Key line: a shallow URL change
}, [searchParams, router]);
return (
<div className="quantum-filter-panel">
<h3>Quantum Filters</h3>
<div className="filter-controls">
<button onClick={() => updateFilter('category', 'transport')}>
Transport
</button>
<button onClick={() => updateFilter('category', 'accommodation')}>
Accommodation
</button>
<button onClick={() => updateFilter('sort', 'price_asc')}>
Price: ascending
</button>
<button onClick={() => updateFilter('sort', 'price_desc')}>
Price: descending
</button>
</div>
</div>
);
}In the above example, clicking on filter buttons updates the URL by adding query parameters, but does not cause a full page reload. This way we preserve the application state, such as form data entered by the user, scroll position, or local component state.
Reading Parameters in Components
To read query parameters in components, we use the useSearchParams hook:
'use client';
import { useSearchParams } from 'next/navigation';
export default function QuantumResults() {
const searchParams = useSearchParams();
// Read query parameters
const category = searchParams.get('category') || 'all';
const sort = searchParams.get('sort') || 'default';
return (
<div className="quantum-results">
<h2>Results for category: {category}</h2>
<p>Sorting: {sort}</p>
{/* Here we render results based on parameters */}
</div>
);
}Shallow Routing Use Cases
1. Filtering and Sorting Systems
An ideal use case for shallow routing is filtering and sorting systems, where the user can dynamically change data display criteria:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function QuantumPropertyFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const applyFilters = useCallback((filters) => {
const params = new URLSearchParams(searchParams.toString());
// Update parameters based on filters
Object.entries(filters).forEach(([key, value]) => {
if (value) {
params.set(key, value.toString());
} else {
params.delete(key);
}
});
// Update URL without page reload
window.history.pushState(null, '', '?' + params.toString());
}, [searchParams, router]);
return (
<div className="quantum-filters">
<div className="filter-section">
<h3>Quantum Property Filters</h3>
<div className="filter-group">
<label>Minimum price:</label>
<input
type="range"
min="0"
max="10000"
step="100"
onChange={(e) => applyFilters({ minPrice: e.target.value })}
/>
</div>
<div className="filter-group">
<label>Location:</label>
<select onChange={(e) => applyFilters({ location: e.target.value })}>
<option value="">All</option>
<option value="quantum-district">Quantum District</option>
<option value="nano-heights">Nano Heights</option>
<option value="ai-valley">AI Valley</option>
</select>
</div>
</div>
</div>
);
}2. Pagination
Shallow routing works excellently in pagination systems, where we want to preserve the current application state while navigating between pages:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function QuantumPagination({ totalPages }) {
const router = useRouter();
const searchParams = useSearchParams();
// Get current page from URL parameters or default to 1
const currentPage = parseInt(searchParams.get('page') || '1', 10);
const goToPage = useCallback((page) => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', page.toString());
// Update URL without page reload
window.history.pushState(null, '', '?' + params.toString());
}, [searchParams, router]);
// Generate pagination buttons
const renderPaginationButtons = () => {
const buttons = [];
for (let i = 1; i <= totalPages; i++) {
buttons.push(
<button
key={i}
onClick={() => goToPage(i)}
className={currentPage === i ? 'active' : ''}
>
{i}
</button>
);
}
return buttons;
};
return (
<div className="quantum-pagination">
<button
disabled={currentPage === 1}
onClick={() => goToPage(currentPage - 1)}
>
Previous
</button>
{renderPaginationButtons()}
<button
disabled={currentPage === totalPages}
onClick={() => goToPage(currentPage + 1)}
>
Next
</button>
</div>
);
}3. Sortable Tables
Another excellent use case is tables with sortable columns:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function QuantumDataTable({ data }) {
const router = useRouter();
const searchParams = useSearchParams();
// Get current sort column and direction
const sortColumn = searchParams.get('sort') || 'name';
const sortDir = searchParams.get('dir') || 'asc';
const toggleSort = useCallback((column) => {
const params = new URLSearchParams(searchParams.toString());
// If clicking on the same column, change sort direction
if (column === sortColumn) {
params.set('dir', sortDir === 'asc' ? 'desc' : 'asc');
} else {
// Otherwise, set new column and default direction
params.set('sort', column);
params.set('dir', 'asc');
}
// Update URL without page reload
window.history.pushState(null, '', '?' + params.toString());
}, [searchParams, router, sortColumn, sortDir]);
// Function to render column headers with sort icons
const renderHeaderCell = (column, label) => {
const isSorted = sortColumn === column;
const icon = isSorted
? (sortDir === 'asc' ? 'โ' : 'โ')
: 'โ';
return (
<th
onClick={() => toggleSort(column)}
className={isSorted ? 'sorted' : ''}
>
{label} {icon}
</th>
);
};
return (
<table className="quantum-data-table">
<thead>
<tr>
{renderHeaderCell('name', 'Name')}
{renderHeaderCell('price', 'Price')}
{renderHeaderCell('rating', 'Rating')}
{renderHeaderCell('date', 'Date')}
</tr>
</thead>
<tbody>
{/* Here we render sorted data */}
{[...data]
.sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
const modifier = sortDir === 'asc' ? 1 : -1;
return aValue < bValue ? -1 * modifier : 1 * modifier;
})
.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.price}</td>
<td>{item.rating}</td>
<td>{item.date}</td>
</tr>
))
}
</tbody>
</table>
);
}Advantages of Shallow Routing
- State preservation - Component state is preserved, ensuring a smooth user experience.
- Better performance - No full page reload means faster interface responses.
- Link sharing capability - The URL is updated, so users can share a link to a specific view with specific filters.
- Browser history - Each change is saved in browser history, so the "back" button works as expected.
Limitations and Notes
SEO compatibility - If filters and sorting are important for SEO, it may be necessary to use static site generation (SSG) for key parameter combinations.
Server vs. Client - Shallow routing only works on the client side, so the initial page render will always be based on parameters received from the server.
Code execution order - Remember that during shallow routing, data functions are not executed, so logic based on these functions will not be triggered.
Error handling - It is good practice to add error handling to avoid issues with invalid URL parameters:
useEffect(() => {
// Check if parameters are valid
const page = parseInt(searchParams.get('page') || '1', 10);
if (isNaN(page) || page < 1 || page > totalPages) {
// Correct invalid parameters
const params = new URLSearchParams(searchParams.toString());
params.set('page', '1');
window.history.replaceState(null, '', '?' + params.toString());
}
}, [searchParams, router, totalPages]);Comparison with Pages Router Methods
If you previously used the Pages Router in Next.js, it's worth noting the difference in shallow routing implementation:
// In the Pages Router (next/router)
router.push('/search?query=quantumtech', undefined, { shallow: true });
// In the App Router (since Next.js 14.1, also in Next.js 16) - the native History API
window.history.pushState(null, '', '/search?query=quantumtech');The shallow option exists only in the next/router router. In the App Router, router.push accepts only the scroll option and always performs a regular navigation, which is why window.history.pushState is used for a shallow address change.
Full Implementation Example: Quantum Properties Search Panel
Below is an example of a full implementation of a search and filtering system for properties in the Quantum Metropolis using shallow routing:
// app/quantum-properties/page.tsx (Server Component)
import { Suspense } from 'react';
import QuantumProperties from './QuantumProperties';
export default function QuantumPropertiesPage() {
return (
<Suspense fallback={<p>Loading filters...</p>}>
<QuantumProperties />
</Suspense>
);
}The search and filter logic itself goes into a Client Component in a separate file:
// app/quantum-properties/QuantumProperties.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect, useCallback } from 'react';
import { fetchProperties } from '@/lib/api';
// Types for quantum properties
interface QuantumProperty {
id: string;
name: string;
price: number;
location: string;
quantumLevel: number;
dimensionalRating: number;
image: string;
}
export default function QuantumProperties() {
const router = useRouter();
const searchParams = useSearchParams();
// Component state
const [properties, setProperties] = useState<QuantumProperty[]>([]);
const [loading, setLoading] = useState(true);
// Get parameters from URL
const location = searchParams.get('location') || 'all';
const minPrice = parseInt(searchParams.get('minPrice') || '0', 10);
const maxPrice = parseInt(searchParams.get('maxPrice') || '10000', 10);
const sort = searchParams.get('sort') || 'price_asc';
const page = parseInt(searchParams.get('page') || '1', 10);
const quantumLevel = parseInt(searchParams.get('quantumLevel') || '0', 10);
// Function to update filters using shallow routing
const updateFilters = useCallback((newFilters: Record<string, string | number>) => {
const params = new URLSearchParams(searchParams.toString());
// Update parameters
Object.entries(newFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value.toString());
} else {
params.delete(key);
}
});
// Always return to first page when changing filters
if (!('page' in newFilters)) {
params.set('page', '1');
}
// Update URL without page reload
window.history.pushState(null, '', '?' + params.toString());
}, [searchParams, router]);
// Fetch data when parameters change
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
// In a real application, parameters would be passed to the API
const data = await fetchProperties({
location,
minPrice,
maxPrice,
quantumLevel,
sort,
page
});
setProperties(data);
} catch (error) {
console.error('Error fetching properties:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [location, minPrice, maxPrice, sort, page, quantumLevel]);
// Sort data based on sort parameter
const sortedProperties = [...properties].sort((a, b) => {
if (sort === 'price_asc') return a.price - b.price;
if (sort === 'price_desc') return b.price - a.price;
if (sort === 'quantum_level') return b.quantumLevel - a.quantumLevel;
return 0;
});
return (
<div className="quantum-properties-page">
<h1>Quantum Properties</h1>
{/* Filters panel */}
<div className="filters-panel">
<h2>Filters</h2>
<div className="filter-group">
<label>Location:</label>
<select
value={location}
onChange={(e) => updateFilters({ location: e.target.value })}
>
<option value="all">All locations</option>
<option value="quantum-district">Quantum District</option>
<option value="nano-heights">Nano Heights</option>
<option value="ai-valley">AI Valley</option>
</select>
</div>
<div className="filter-group">
<label>Minimum price: {minPrice} QC</label>
<input
type="range"
min="0"
max="10000"
step="100"
value={minPrice}
onChange={(e) => updateFilters({ minPrice: e.target.value })}
/>
</div>
<div className="filter-group">
<label>Maximum price: {maxPrice} QC</label>
<input
type="range"
min="0"
max="10000"
step="100"
value={maxPrice}
onChange={(e) => updateFilters({ maxPrice: e.target.value })}
/>
</div>
<div className="filter-group">
<label>Quantum level (min): {quantumLevel}</label>
<input
type="range"
min="0"
max="10"
value={quantumLevel}
onChange={(e) => updateFilters({ quantumLevel: e.target.value })}
/>
</div>
<div className="filter-group">
<label>Sorting:</label>
<select
value={sort}
onChange={(e) => updateFilters({ sort: e.target.value })}
>
<option value="price_asc">Price: ascending</option>
<option value="price_desc">Price: descending</option>
<option value="quantum_level">Quantum level</option>
</select>
</div>
</div>
{/* Search results */}
<div className="search-results">
{loading ? (
<div className="loading-spinner">Loading...</div>
) : (
<>
<h2>Found {sortedProperties.length} properties</h2>
<div className="properties-grid">
{sortedProperties.map(property => (
<div key={property.id} className="property-card">
<img src={property.image} alt={property.name} />
<h3>{property.name}</h3>
<p className="price">{property.price} QC</p>
<p className="location">{property.location}</p>
<p className="quantum-level">
Quantum level: {property.quantumLevel}/10
</p>
</div>
))}
</div>
{/* Pagination */}
<div className="pagination">
<button
disabled={page === 1}
onClick={() => updateFilters({ page: page - 1 })}
>
Previous
</button>
<span className="current-page">Page {page}</span>
<button onClick={() => updateFilters({ page: page + 1 })}>
Next
</button>
</div>
</>
)}
</div>
</div>
);
}A static page whose Client Component reads useSearchParams needs a <Suspense> boundary above it: without one, next build fails with โuseSearchParams() should be wrapped in a suspense boundaryโ.
Summary
Shallow Routing in Next.js 16 is a powerful technique that allows creating interactive and dynamic user interfaces while preserving application state during URL updates. It is an ideal solution for filtering, sorting, pagination, and other use cases where we want to smoothly update page content without a full reload.
Just as "Shallow Trajectory Change" in the Quantum Metropolis allows vehicles to dynamically change routes without stopping, Shallow Routing in Next.js allows dynamic URL parameter updates without interrupting the user experience.
In the next lesson you will combine Parallel Routes, Intercepting Routes and Route Groups into advanced layouts.
Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does shallow routing in Next.js enable when changing the URL?
2. Which 'next/navigation' hooks do you use together with window.history.pushState for shallow routing with query parameters?