Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 015 / 100

The URL is state: a React & Next.js guide for developers

Filters, tabs and pagination belong in search params.

Expona Admin7 min read

The URL is state

Every time a user navigates away from your page and loses their filter selections, pagination, or tab choices, you've missed an opportunity to make your app feel solid and predictable. The URL is state—not localStorage, not React state, not a cookie—and treating it as your source of truth for filters, tabs, and pagination transforms how your users experience your application.

Why URL state matters in production

I've shipped enough e-commerce platforms and trading dashboards to know that users expect three things they often don't get: the ability to share a filtered view with a colleague, the back button to work predictably, and their selections to survive a page refresh without any tricks. These aren't nice-to-have features in real applications—they're baseline expectations.

When you store filter state only in React memory or localStorage, you're creating invisible walls. Your user finds a specific product filtered by price range and material, wants to send it to a teammate, and realizes they have to describe what they filtered for instead of just copying the link. Worse, when they hit refresh or revisit the tab tomorrow, everything resets. This breaks the mental model of "URLs represent pages and their content."

The immediate benefit of URL state is that links become shareable by default. In our e-commerce system, when a user filtered products by category, price, and rating, that state was encoded in the URL. A link like /products?category=electronics&minPrice=50&maxPrice=300&minRating=4 told the complete story. A colleague could click that link and see exactly what the first user was viewing.

Back buttons also work correctly. If you navigate from a filtered view to a product detail page, your browser history preserves the URL with all query parameters. Hitting back doesn't reset your filters—it takes you back to the exact same state you left. This is browser behavior you get for free when you use URL state.

Here's how this looks in a basic Next.js page component:

tsx
// app/products/page.tsx
import { Suspense } from 'react';
import ProductList from './ProductList';
import Filters from './Filters';

interface SearchParams {
  category?: string;
  minPrice?: string;
  maxPrice?: string;
  page?: string;
}

export default function ProductsPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  return (
    <div>
      
      Loading products...</div>}>
        
      
    
  );
}

The searchParams object passed to your page component contains all URL query parameters. No parsing, no manual extraction. The component receives the current state from the URL and renders accordingly.

Survives refresh without storage

I've debugged localStorage bugs at 2 AM more times than I care to remember. Keys colliding across features, data becoming stale, browser privacy modes breaking assumptions, and the cognitive overhead of managing serialization and cache invalidation. URL state eliminates this entire category of problems.

When a user has filtered a CRM's contact list to show only active leads in the tech sector, that state lives in the URL: /crm/contacts?status=active&industry=technology. They refresh the page. The server receives the request with those query parameters intact and renders the correct view immediately. No race conditions between localStorage hydration and component render. No checking if localStorage exists. No "why is my filter gone?"

This also means your analytics are cleaner. The URL reflects the user's actual view, so if you log page views or errors, you have the full context baked into the URL itself.

useSearchParams on the client, searchParams in pages

Next.js gives you two tools for working with URL state depending on whether you're in a Server Component or Client Component. In a Server Component (which includes your page), you receive searchParams as a prop. In a Client Component, you use the useSearchParams hook.

Let me show both in context:

tsx
// app/products/Filters.tsx (Client Component)
'use client';

import { useSearchParams, useRouter } from 'next/navigation';

export default function Filters() {
  const searchParams = useSearchParams();
  const router = useRouter();

  const category = searchParams.get('category') || '';
  const minPrice = searchParams.get('minPrice') || '';

  const handleCategoryChange = (newCategory: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('category', newCategory);
    params.delete('page'); // Reset to page 1 when filters change
    router.push(`?${params.toString()}`);
  };

  return (
    <div>
       handleCategoryChange(e.target.value)}
        placeholder="Filter by category"
      />
      <span>Price: {minPrice}</span>
    </div>
  );
}

In a Client Component, useSearchParams returns a URLSearchParams object. You read current values with get(), modify them, and use router.push() to update the URL. The router.push() call doesn't perform a full page reload—it updates the URL and triggers a re-render with the new searchParams.

The Server Component version is even simpler—you already have searchParams available:

tsx
// app/products/page.tsx (Server Component)
export default function ProductsPage({
  searchParams,
}: {
  searchParams: { category?: string; page?: string };
}) {
  const category = searchParams.category || '';
  const page = parseInt(searchParams.page || '1', 10);

  // Fetch products based on category and page
  const products = fetchProducts(category, page);

  return (
    <div>
      {/* Render products */}
    </div>
  );
}

nuqs adds type-safe search param state

Managing URLSearchParams manually works, but it's repetitive and error-prone. You write the same boilerplate to read, parse, update, and push URL changes dozens of times across your app. This is where nuqs comes in.

Nuqs (Next.js URL Query String) is a library that treats URL search parameters like React state. You define parsers for each parameter, and nuqs handles serialization, deserialization, and URL updates with a hook that mirrors useState.

Install it with npm install nuqs, then wrap your app with the provider:

tsx
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/app';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      
        {children}
      
    
  );
}

Now use it in your Client Components:

tsx
// app/products/Filters.tsx
'use client';

import { useQueryState } from 'nuqs';

export default function Filters() {
  const [category, setCategory] = useQueryState('category', {
    defaultValue: '',
    parse: String,
    serialize: String,
  });

  const [minPrice, setMinPrice] = useQueryState('minPrice', {
    defaultValue: '',
    parse: (v) => v ? parseInt(v, 10) : null,
    serialize: (v) => v?.toString() || '',
  });

  return (
    <div>
       setCategory(e.target.value)}
        placeholder="Filter by category"
      />
       setMinPrice(e.target.value ? parseInt(e.target.value, 10) : null)}
        placeholder="Min price"
      />
    </div>
  );
}

The API mirrors useState—you get a value and a setter. Updating the state automatically updates the URL. Nuqs also batches updates, so rapid filter changes don't spam history entries.

For more complex types, you can define custom parsers:

tsx
const tagsParser = {
  parse: (v: string) => (v ? v.split(',') : []),
  serialize: (v: string[]) => v.join(','),
};

const [tags, setTags] = useQueryState('tags', tagsParser);

Common mistakes

Storing everything in URL state: Only put stable, serializable values in the URL. Skip loading states, error messages, and internal UI state like "is this dropdown open?" Those belong in React state. A good rule: if a user should see the same thing when they refresh or share the link, put it in the URL.

Forgetting to reset pagination when filters change: If a user is on page 3 of a filtered product list and changes a filter, the URL might still say page=3 but there might only be 1 page of results now. Always reset to page 1 when filters change.

Not handling missing or invalid search params: Always provide sensible defaults. If someone manually edits the URL and sets ?page=abc, your code should handle that gracefully. Use parsing functions that validate types.

Mixing manual URL manipulation with libraries like nuqs: Pick one approach and stick with it. Mixing router.push() calls with useQueryState leads to confusion about which code is the source of truth.

Key takeaways

  • The URL is state. Filters, tabs, pagination, and any user selection that should survive a refresh and be shareable belongs in search parameters.
  • URL state gives you shareable links and predictable back button behavior automatically—no extra work required.
  • In Next.js, use searchParams in Server Components and useSearchParams in Client Components to read URL state.
  • Use nuqs to eliminate boilerplate and get type-safe, parsed URL state that feels like React state.
  • Only put stable, serializable values in the URL. UI state and transient data like loading indicators stay in React state.
  • Always provide defaults and validate types when reading search params. Users will manually edit URLs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles