State management is often the first challenge developers face when building React applications. While we typically reach for Redux, Zustand, or Context API, there’s a powerful, often overlooked state manager built right into every web application: the URL itself.
In this article, we’ll explore how to leverage URL state management in Next.js to create more shareable, bookmarkable, and SEO-friendly applications.
Why Use the URL for State Management?
Before diving into implementation, let’s understand why URL state management deserves a place in your Next.js toolkit.
Shareability: Imagine a user configuring a complex dashboard with multiple filters, sorting options, and view preferences. With URL state, they can simply copy the URL and share their exact view with colleagues. No “export config” buttons needed.
Bookmarkability: Users can bookmark specific application states and return to them instantly. This is particularly valuable for search interfaces, data visualizations, or any view that users might want to revisit.
Browser History: The back and forward buttons work intuitively. Users expect these browser fundamentals to work, and URL state makes this natural.
SEO Benefits: Search engines can index different states of your application. Each unique filter combination or view becomes a discoverable page.
Server-Side Rendering: Next.js can read URL parameters during server-side rendering, allowing you to fetch the right data before the page even loads.
Understanding Next.js Routing
Next.js provides two routing paradigms: the Pages Router and the App Router. Each handles URL state slightly differently.
The Pages Router uses the useRouter hook from next/router, while the App Router uses useSearchParams and useRouter from next/navigation. The App Router also introduces server components, which can read search parameters directly without client-side hooks.
Implementing URL State with the App Router
Let’s build a practical example: a product filtering interface. Here’s how to manage filter state entirely through the URL.
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback } from 'react';export default function ProductFilters() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams(); const category = searchParams.get('category') || 'all';
const sortBy = searchParams.get('sortBy') || 'name';
const minPrice = searchParams.get('minPrice') || ''; const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
return params.toString();
},
[searchParams]
); const updateFilter = (name: string, value: string) => {
router.push(pathname + '?' + createQueryString(name, value));
}; return (
<div className="filters">
<select
value={category}
onChange={(e) => updateFilter('category', e.target.value)}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select> <select
value={sortBy}
onChange={(e) => updateFilter('sortBy', e.target.value)}
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select> <input
type="number"
placeholder="Min Price"
value={minPrice}
onChange={(e) => updateFilter('minPrice', e.target.value)}
/>
</div>
);
}Implementing URL State with the Pages Router
The Pages Router approach uses a slightly different API but achieves the same result.
import { useRouter } from 'next/router';export default function ProductFilters() {
const router = useRouter();
const { category = 'all', sortBy = 'name', minPrice = '' } = router.query; const updateFilter = (name: string, value: string) => {
const query = { ...router.query };
if (value) {
query[name] = value;
} else {
delete query[name];
}
router.push({
pathname: router.pathname,
query,
}, undefined, { shallow: true });
}; // Render implementation similar to above
}Notice the shallow: true option. This prevents Next.js from re-running data fetching methods, giving you fine-grained control over when to fetch new data based on URL changes.
Advanced Patterns and Best Practices
Debouncing URL Updates
For inputs like search boxes, you don’t want to update the URL on every keystroke. Implement debouncing to reduce history clutter and improve performance.
import { useDebouncedCallback } from 'use-debounce';const debouncedUpdate = useDebouncedCallback((value: string) => {
updateFilter('search', value);
}, 500);<input onChange={(e) => debouncedUpdate(e.target.value)} />Type-Safe URL Parameters
Create a custom hook that provides type safety for your URL parameters.
interface FilterParams {
category?: 'all' | 'electronics' | 'clothing';
sortBy?: 'name' | 'price' | 'rating';
minPrice?: number;
}function useFilterParams(): FilterParams {
const searchParams = useSearchParams();
return {
category: searchParams.get('category') as FilterParams['category'] || 'all',
sortBy: searchParams.get('sortBy') as FilterParams['sortBy'] || 'name',
minPrice: searchParams.get('minPrice')
? parseInt(searchParams.get('minPrice')!)
: undefined,
};
}Handling Complex State
For complex objects, consider serializing them as JSON in the URL, or use a compression library for lengthy state.
const updateComplexState = (state: object) => {
const encoded = btoa(JSON.stringify(state));
updateFilter('state', encoded);
};const getComplexState = () => {
const encoded = searchParams.get('state');
return encoded ? JSON.parse(atob(encoded)) : {};
};Server Component Integration
In the App Router, server components can read search parameters directly, enabling server-side filtering and data fetching.
// app/products/page.tsx
interface PageProps {
searchParams: { category?: string; sortBy?: string };
}
export default async function ProductsPage({ searchParams }: PageProps) {
const products = await fetchProducts({
category: searchParams.category || 'all',
sortBy: searchParams.sortBy || 'name',
}); return <ProductList products={products} />;
}When NOT to Use URL State
While powerful, URL state isn’t appropriate for everything. Avoid using it for:
- Sensitive information: Passwords, tokens, or personal data should never appear in URLs
- Large datasets: URLs have practical length limits (typically around 2000 characters)
- Temporary UI state: Modal open/closed states or hover effects should use local state
- High-frequency updates: Rapidly changing values like animation frames or scroll positions
Performance Considerations
URL updates trigger navigation events, which can impact performance if not handled carefully. Use the shallow option in the Pages Router to prevent unnecessary data fetching. In the App Router, consider using router.replace() instead of router.push() to avoid cluttering browser history for certain updates.
Conclusion
URL state management in Next.js bridges the gap between traditional web navigation and modern single-page applications. It makes your applications more user-friendly, shareable, and SEO-optimized while maintaining clean, predictable code.
Start by identifying which parts of your application state would benefit from being in the URL. Begin with obvious candidates like search queries and filters, then gradually expand to other areas where shareability and bookmarkability add value.
The URL isn’t just an address — it’s a powerful state management tool that’s been hiding in plain sight all along. Use it wisely, and your users will thank you for applications that just work the way the web was meant to work.
What patterns have you discovered for managing state in Next.js? Share your experiences in the comments below.