next-url-state
v1.0.7
Published
React hooks for managing state in URL search parameters with Next.js
Maintainers
Readme
next-url-state
Next.js URL state management that gets out of your way.
Used in production by:
Table of Contents
- Why you should use this library?
- Installation
- Quick Start
- Migration Guide
- API Reference
- Options
- How It Works
- Configuration
- TypeScript Support
- Compatibility
- FAQ / Troubleshooting
- Contributing
- License
Why you should use this library?
The Problem with Vanilla Next.js
Managing URL state in Next.js requires a lot of boilerplate and comes with several pain points
Problems:
- Lots of boilerplate - Manual state synchronization, useEffect hooks, query object spreading
- Race conditions - State and URL can get out of sync during navigation
- Performance issues - Every keystroke triggers a router update, causing re-renders
- Type safety - URL params are always
string | string[] | undefined, requiring type guards - Complex updates - Managing multiple parameters requires careful query object manipulation
- Router differences - Different APIs for Pages Router (
next/router) vs App Router (next/navigation)
The Solution with next-url-state is slim and simple
Benefits:
- ✅ Minimal code - One line hook replaces 20+ lines of boilerplate
- ✅ Automatic sync - State and URL stay in sync automatically
- ✅ Optimized performance - Built-in batching ( default: 250ms, configurable ) and optimistic updates
- ✅ Type safety & Flexibility - Support for basic and custom data types, including custom serialization
- ✅ Simple API - Works just like
useStatebut with URL persistence - ✅ Router agnostic - Works with both Pages Router and App Router — hooks have identical API regardless of router
- ✅ Zero Dependencies - Lightweight with only peer dependencies on React and Next.js
This library handles all the complexity of URL state management, letting you focus on building features instead of wrestling with router APIs.
How It Compares
| Feature | next-url-state | nuqs | use-query-params |
|---|:---:|:---:|:---:|
| Pages Router (/pages) | ✅ | ✅ | ✅ |
| App Router (/app) | ✅ | ✅ | ⚠️ adapter required |
| React Server Components | ✅ read-only | ✅ read-only | ❌ |
| Works like useState | ✅ | ✅ | ✅ |
| Optimistic UI updates | ✅ | ❌ | ❌ |
| Automatic batching | ✅ | ✅ | ❌ |
| Custom parse / serialize | ✅ | ✅ | ✅ |
| TypeScript support | ✅ | ✅ | ✅ |
| Zero dependencies | ✅ | ❌ | ❌ |
| Bundle size (min+gzip) | |
|
|
⚠️ Competitor features reflect best available knowledge — check their docs for the latest.
Installation
npm install next-url-stateyarn add next-url-statepnpm add next-url-stateQuick Start
30-Second Start
Two steps — pick the right provider for your router:
// Step 1 — wrap your app once
// pages/_app.tsx (Pages Router)
import { UrlParamsPagesRouterProvider } from 'next-url-state';
export default function App({ Component, pageProps }) {
return (
<UrlParamsPagesRouterProvider>
<Component {...pageProps} />
</UrlParamsPagesRouterProvider>
);
}// app/layout.tsx (App Router)
import { UrlParamsProvider } from 'next-url-state';
export default function RootLayout({ children }) {
return (
<html><body>
<UrlParamsProvider>{children}</UrlParamsProvider>
</body></html>
);
}// Step 2 — use it anywhere in both routers, just like useState
'use client'; // only needed for App Router
import { useUrlParam } from 'next-url-state';
function Search() {
const [query, setQuery] = useUrlParam('q');
return <input value={query ?? ''} onChange={e => setQuery(e.target.value)} />;
}That's it. The URL updates automatically. For router-specific setup details see below.
Pages Router Setup
1. Wrap your app with UrlParamsPagesRouterProvider
// pages/_app.tsx
import { UrlParamsPagesRouterProvider } from 'next-url-state';
const MyApp = ({ Component, pageProps }) => {
return (
<UrlParamsPagesRouterProvider>
<Component {...pageProps} />
</UrlParamsPagesRouterProvider>
);
}
export default MyApp;Why
UrlParamsPagesRouterProviderand notUrlParamsProvider?
UrlParamsPagesRouterProvideris a thin wrapper that readsrouter.asPathfromnext/routerand passes it to the provider as the initial server-side path. This means URL parameters are available during SSR, so the server and client render identically — no hydration mismatch.
UrlParamsProvideris intended for App Router apps — use it there, not in Pages Router. In a Pages Router app it starts with an empty param set on the server (no access tonext/router), which causes hydration mismatches.
2. Use the hooks in your components
import { useUrlParam } from 'next-url-state';
const SearchComponent = () => {
const [query, setQuery] = useUrlParam('q');
return (
<input
value={query || ''}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}App Router Setup
1. Create a client provider component (if only this provider is needed proceed with step 2)
// app/providers.tsx
'use client';
import { UrlParamsProvider } from 'next-url-state';
export const Providers = ({ children }: { children: React.ReactNode }) => {
return <UrlParamsProvider>{children}</UrlParamsProvider>;
}2. Use it in your root layout
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* UrlParamsProvider can be used directly if no other provider is needed */}
<Providers>{children}</Providers>
</body>
</html>
);
}3. Use the hooks in your components
// app/search/page.tsx
'use client';
import { useUrlParam } from 'next-url-state';
export default function SearchPage() {
const [query, setQuery] = useUrlParam('q');
return (
<input
value={query || ''}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}Note: Components using these hooks must be client components (
'use client')
React Server Components
For React Server Components, use the createRscAdapter function to read URL parameters (no writing):
// app/products/page.tsx (Server Component)
import { createRscAdapter } from 'next-url-state/rsc';
interface PageProps {
searchParams: Promise<Record<string, string | string[]>>;
}
export default async function ProductsPage({ searchParams }: PageProps) {
const params = await searchParams;
const adapter = createRscAdapter(
'/products',
new URLSearchParams(params as Record<string, string>)
);
const currentPath = adapter.getCurrentPath();
// → "/products?category=electronics&sort=price"
// Note: adapter.updateUrl() is a no-op in RSC
// For URL updates, use a Client Component
return (
<div>
<p>Current path: {currentPath}</p>
{/* Pass data to Client Components for interactivity */}
</div>
);
}Note:
updateUrl()returnsfalseand logs a warning in development mode, since URL updates require client-side JavaScript.
Migration Guide
From Next.js Pages Router (the /pages directory)
Before — manual state sync, boilerplate, and race-condition-prone:
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
const SearchPage = () => {
const router = useRouter();
const [search, setSearch] = useState((router.query.q as string) ?? '');
useEffect(() => {
setSearch((router.query.q as string) ?? '');
}, [router.query.q]);
const handleChange = (value: string) => {
setSearch(value);
router.replace(
{ query: { ...router.query, q: value } },
undefined,
{ shallow: true }
);
};
};After — one line, no boilerplate:
import { useUrlParam } from 'next-url-state';
const SearchPage = () => {
const [search, setSearch] = useUrlParam('q');
};Provider change in _app.tsx:
// Before
import { useRouter } from 'next/router'; // no provider needed, but lots of manual work
// After
import { UrlParamsPagesRouterProvider } from 'next-url-state';
const MyApp = ({ Component, pageProps }) => (
<UrlParamsPagesRouterProvider>
<Component {...pageProps} />
</UrlParamsPagesRouterProvider>
);Note: Use
UrlParamsPagesRouterProvider(notUrlParamsProvider) in Pages Router apps to avoid hydration mismatches. It readsrouter.asPathautomatically so URL params are available during SSR.
From Next.js App Router (the /app directory)
Before — verbose URL construction on every update:
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback } from 'react';
const SearchPage = () => {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const search = searchParams.get('q');
const setSearch = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set('q', value);
else params.delete('q');
router.replace(`${pathname}?${params.toString()}`);
},
[searchParams, router, pathname]
);
};After:
'use client';
import { useUrlParam } from 'next-url-state';
const SearchPage = () => {
const [search, setSearch] = useUrlParam('q');
};Provider change in layout.tsx:
// Before — no provider, but each component wires up routing manually
// After
import { UrlParamsProvider } from 'next-url-state';
export default function RootLayout({ children }) {
return (
<html>
<body>
<UrlParamsProvider>{children}</UrlParamsProvider>
</body>
</html>
);
}From use-query-params
Provider — simpler setup, no adapter required:
// Before
import { QueryParamProvider } from 'use-query-params';
import { NextAdapter } from 'next-query-params';
<QueryParamProvider adapter={NextAdapter}>
<Component {...pageProps} />
</QueryParamProvider>
// After (Pages Router)
import { UrlParamsPagesRouterProvider } from 'next-url-state';
<UrlParamsPagesRouterProvider>
<Component {...pageProps} />
</UrlParamsPagesRouterProvider>Hook equivalents:
| use-query-params | next-url-state |
|---|---|
| useQueryParam('q', StringParam) | useUrlParam('q') |
| useQueryParam('page', NumberParam) | useUrlParam<number>('page', { parse: (v) => parseInt(v ?? '1', 10), serialize: String }) |
| useQueryParam('flag', BooleanParam) | useUrlParam<boolean>('flag', { parse: (v) => v === 'true', serialize: (v) => v ? 'true' : undefined }) |
| useQueryParam('tags', ArrayParam) | useUrlParamArray('tags') |
| useQueryParams({ q: StringParam, page: NumberParam }) | useUrlParams(['q', 'page']) |
Example side-by-side:
// Before
import { useQueryParam, useQueryParams, StringParam, NumberParam, ArrayParam } from 'use-query-params';
const [search, setSearch] = useQueryParam('q', StringParam);
const [page, setPage] = useQueryParam('page', NumberParam);
const [tags, setTags] = useQueryParam('tags', ArrayParam);
const [{ q, page }, setQuery] = useQueryParams({ q: StringParam, page: NumberParam });
// After
import { useUrlParam, useUrlParamArray, useUrlParams } from 'next-url-state';
const [search, setSearch] = useUrlParam('q');
const [page, setPage] = useUrlParam<number>('page', { parse: (v) => parseInt(v ?? '1', 10), serialize: String });
const [tags, setTags] = useUrlParamArray('tags');
const [{ q, page }, setQuery] = useUrlParams(['q', 'page']);API Reference
useUrlParam
Hook for managing a single URL parameter.
const [value, setValue] = useUrlParam<T>(paramName, options?);Examples:
// Simple string parameter
const [name, setName] = useUrlParam('name');
// With type parsing (number)
const [age, setAge] = useUrlParam<number>('age', {
parse: (value) => (value ? parseInt(value, 10) : undefined),
serialize: (value) => value?.toString(),
});
// Boolean parameter
const [enabled, setEnabled] = useUrlParam<boolean>('enabled', {
parse: (value) => value === 'true',
serialize: (value) => (value ? 'true' : undefined),
});
// With history entry (creates browser history entry)
await setName('John', { historyEntry: true });
// Without shallow routing (triggers data fetching)
await setName('John', { shallow: false });useUrlParamArray
Hook for managing URL parameters with multiple values.
const [values, setValues] = useUrlParamArray<T>(paramName, options?);Example:
// Multiple tags: /page?tag=react&tag=nextjs&tag=typescript
// RECOMMENDED: Always use a parser to ensure you get an array
const [tags, setTags] = useUrlParamArray<string[]>('tag', {
parse: (value) => {
if (!value) return [];
if (Array.isArray(value)) return value;
return [value]; // Single value becomes array
},
});
// Now tags is always string[] (never undefined or string)
setTags([...tags, 'new-tag']); // ✅ Safe to use array methods
// Simple usage (may return string | string[] | undefined)
const [tags, setTags] = useUrlParamArray('tag');
// tags could be: undefined, 'single', or ['multiple', 'values']💡 Best Practice: Always provide a
parsefunction to ensure consistent array behavior and avoid.map()errors.
useUrlParams
Hook for managing multiple URL parameters at once.
const [params, setParams] = useUrlParams<Keys>(keys?);Examples:
// Subscribe to all URL parameters
const [params, setParams] = useUrlParams();
// params = { q: 'search', page: '1', sort: 'date' }
// Subscribe to specific parameters only
const [params, setParams] = useUrlParams(['q', 'page']);
// Only re-renders when 'q' or 'page' changes
// Update multiple parameters at once
setParams({ q: 'new search', page: '1' });useUrlParamsArray
Similar to useUrlParams but returns arrays for each param.
const [params, setParams] = useUrlParamsArray<Keys>(keys?);Example:
const [params, setParams] = useUrlParamsArray(['tags', 'categories']);
// params = { tags: ['react', 'nextjs'], categories: ['web'] }useUrlParamValue
Read-only hook for getting a URL parameter value without setter.
const value = useUrlParamValue<T>(paramName, options?);Example:
const currentPage = useUrlParamValue<number>('page', {
parse: (value) => (value ? parseInt(value, 10) : 1),
});Advanced Usage
Custom Types with Parsing and Serialization
interface Filters {
minPrice: number;
maxPrice: number;
category: string;
}
const [filters, setFilters] = useUrlParam<Filters>('filters', {
parse: (value) => {
if (!value) return { minPrice: 0, maxPrice: 1000, category: 'all' };
return JSON.parse(value);
},
serialize: (filters) => JSON.stringify(filters),
});Pagination Example
const PaginatedList = () => {
const [page, setPage] = useUrlParam<number>('page', {
parse: (value) => (value ? parseInt(value, 10) : 1),
serialize: (value) => value?.toString(),
});
const [itemsPerPage] = useUrlParam<number>('limit', {
parse: (value) => (value ? parseInt(value, 10) : 10),
serialize: (value) => value?.toString(),
});
return (
<div>
<div>Page {page}</div>
<button onClick={() => setPage(page - 1)} disabled={page <= 1}>
Previous
</button>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
);
}Search with Filters
const ProductSearch = () => {
const [search, setSearch] = useUrlParam('q');
const [categories, setCategories] = useUrlParamArray('category');
const [priceRange, setPriceRange] = useUrlParam('price');
const [allFilters, setAllFilters] = useUrlParams([
'q',
'category',
'price',
'sort',
]);
const clearFilters = () => {
setAllFilters({ q: undefined, category: undefined, price: undefined });
};
return (
<div>
<input
value={search || ''}
onChange={(e) => setSearch(e.target.value)}
/>
<button onClick={clearFilters}>Clear Filters</button>
</div>
);
}Options
UrlChangeOptions
Options that can be passed to setter functions:
interface UrlChangeOptions {
/**
* Shallow routing - doesn't run data fetching methods
* @default true
*/
shallow?: boolean;
/**
* Create a browser history entry
* @default false
*/
historyEntry?: boolean;
}Example:
// Create history entry (browser back button will undo this)
await setQuery('search term', { historyEntry: true });
// Trigger data fetching (getServerSideProps, etc.)
await setQuery('search term', { shallow: false });How It Works
This library provides optimistic updates with URL synchronization:
- Immediate UI updates - When you call a setter, the UI updates immediately
- URL synchronization - The URL is updated in the background using Next.js Router (next-router or App router)
- Smart batching - Multiple updates within the debounce window are batched into a single URL change
- Minimal re-renders - Only components subscribed to changed parameters re-render
Configuration
Library defaults are defined in src/config.ts and can be adjusted to tune behavior:
| Constant | Default | Description |
|---|---|---|
| HISTORY_DEBOUNCE_MS | 250 | Minimum interval (ms) between URL updates that create separate browser history entries. Rapid changes within this window are coalesced into a single entry, preventing the back button from stepping through every keystroke. |
// src/config.ts
export const HISTORY_DEBOUNCE_MS = 250;Note for contributors: This is a compile-time constant. To change the debounce for your own needs, clone the repo, edit
src/config.ts, and build the package locally. End users of the published package cannot configure this at runtime — if you need runtime configurability, please open an issue.
TypeScript Support
Full TypeScript support with type inference:
// Type is inferred as string | undefined
const [name, setName] = useUrlParam('name');
// Explicit typing
const [count, setCount] = useUrlParam<number>('count', {
parse: (v) => parseInt(v || '0', 10),
serialize: (v) => v.toString(),
});
// Multiple params with specific keys
const [params, setParams] = useUrlParams(['search', 'page'] as const);
// params type: { search: string | undefined; page: string | undefined }Compatibility
- Next.js: >= 12.0.0 (Pages Router and App Router)
- React: >= 18.0.0
Router Support
This library supports both Next.js routing systems:
✅ Pages Router (next/router)
- Fully supported with all features
- Shallow routing enabled by default
- Use
UrlParamsPagesRouterProviderinpages/_app.tsx— it readsrouter.asPathfromnext/routerand seeds the provider with the correct path during SSR. This mirrors exactly what Next.js itself does:router.asPathis serialized into__NEXT_DATA__and available on both server and client, so URL params are consistent across SSR and hydration with zero configuration. UrlParamsProvideris intended for App Router — don't use it in Pages Router apps.
✅ App Router (next/navigation)
- Fully supported with all features
- Uses
UrlParamsProviderinapp/layout.tsx— internally wrapsuseSearchParams()fromnext/navigationand applies a mounted guard to prevent hydration mismatches. App Router client components are always ready on the client, so noasPathseed is needed. - Note: App Router doesn't support shallow routing (handled gracefully)
✅ React Server Components (RSC)
- Read-only access to URL parameters
- Use
createRscAdapterfromnext-url-state/rscfor Server Components - Setter is a no-op (URL updates require client-side JavaScript)
Summary: which provider to use?
| App type | Provider | Why |
|---|---|---|
| Pages Router (/pages) | UrlParamsPagesRouterProvider | Reads router.asPath on server — no hydration mismatch |
| App Router (/app) | UrlParamsProvider | Wraps useSearchParams() with a mounted guard |
| Both routers — hooks | useUrlParam, useUrlParams, … | Identical API regardless of router |
See the examples folder for working demos of all supported router types:
- examples/next-app — App Router example (port 3004)
- examples/pages-router — Pages Router example (port 3005)
FAQ / Troubleshooting
Why not just use useSearchParams() directly?
useSearchParams (App Router) and router.query (Pages Router) require you to wire up reading, writing, and URL construction yourself every single time. For a single param that means three hooks, manual URLSearchParams construction, careful spreading to preserve other params, and no batching. next-url-state reduces all of that to one line that behaves like useState.
Does this work with SSR / Server-Side Rendering?
Yes — in two ways:
- Pages Router: use
UrlParamsPagesRouterProviderinpages/_app.tsx. It readsrouter.asPathfromnext/routeron the server, so URL params are seeded correctly during SSR. The server and client render identically — no hydration mismatch. - App Router: use
UrlParamsProviderinapp/layout.tsx. It wrapsuseSearchParams()with a mounted guard, which is the standard hydration-safe pattern for App Router client components. - React Server Components: use
createRscAdapterfromnext-url-state/rscfor read-only access. Setters are no-ops in RSC since URL updates require client-side JavaScript.
I'm getting a React hydration mismatch — server and client render differently for components that read URL params.
This happens in Pages Router apps when UrlParamsProvider is used instead of UrlParamsPagesRouterProvider. UrlParamsProvider cannot access next/router on the server, so it starts with empty URL params — meaning a component that opens an accordion when ?section=true is present will render closed on the server but open on the client.
Fix: replace UrlParamsProvider with UrlParamsPagesRouterProvider in pages/_app.tsx:
// Before
import { UrlParamsProvider } from 'next-url-state';
// ...
<UrlParamsProvider>{children}</UrlParamsProvider>
// After
import { UrlParamsPagesRouterProvider } from 'next-url-state';
// ...
<UrlParamsPagesRouterProvider>{children}</UrlParamsPagesRouterProvider>How does batching work exactly?
Every setter call updates the UI immediately (optimistic update), but the actual URL write is debounced. All setter calls that happen within the HISTORY_DEBOUNCE_MS window (default 250 ms) are merged into a single router.replace / router.push call. This means typing into a search input doesn't flood the browser history with one entry per keystroke — only a single history entry is created once the user pauses. The debounce is configurable.
Does batching work across multiple components?
Yes. All hooks share the same internal store via UrlParamsProvider. Updates from different components within the same debounce window are merged into one URL change.
Why do I need UrlParamsProvider?
The provider creates the shared reactive store that all hooks read from and write to. Without it, hooks can't share state between components or batch their updates together. If you forget it, hooks will throw an error pointing you to add the provider.
What happens when I set a value to undefined?
The parameter is removed from the URL entirely, keeping it clean. For example, setSearch(undefined) turns ?q=hello&page=2 into ?page=2.
Does this work with the browser's Back / Forward buttons?
Yes. When historyEntry: true is passed to a setter, a new browser history entry is created and the back button will undo that change. By default (historyEntry: false) the URL is replaced without adding a history entry, which is the right default for things like search inputs.
Can I use this outside of Next.js?
No. The library depends on Next.js routing APIs (next/router for the Pages Router, next/navigation for the App Router) and is not designed for plain React or other frameworks.
How do I set up next-url-state in tests (Jest / Vitest) for a Pages Router project?
In a jsdom test environment, window exists but window.__NEXT_DATA__ is not set. Without it, the library cannot detect that it is running inside a Pages Router app and falls back to the App Router adapter, which causes errors like:
Error: invariant expected app router to be mountedAdd the following to your test setup file (e.g. vitest.setup.ts or jest.setup.ts):
if (typeof window !== "undefined") {
// Signal Pages Router to next-url-state
window.__NEXT_DATA__ ??= {
props: {},
page: "/",
query: {},
buildId: "test",
};
}This mirrors what Next.js sets in production and lets the library pick the correct Pages Router adapter. No equivalent setup is needed for App Router projects.
Contributing
Contributions are welcome!
The contributing guide helps you get started with setting up the development environment and explains the development workflow.
License
next-url-state is licensed under the MIT License.
Acknowledgments
This library was inspired by the need for better URL state management in Next.js applications.
