Article

Mastering URL State Management in Next.js: A Complete Guide

5 min read
Originally published on Medium. Read, clap or comment on Medium ↗

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:

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.

← All articles Discuss on Medium ↗