nuqs
Type-safe URL search params state management with React hooks
Beztack uses nuqs through the centralized @beztack/state package for type-safe URL search params management, making it easy to synchronize component state with the URL.
Why nuqs?
Traditional URL search params handling is fragile:
- ❌ Manual string parsing and serialization
- ❌ No type safety
- ❌ Inconsistent behavior across frameworks
- ❌ SSR/SSG compatibility issues
- ❌ Boilerplate code for every param
nuqs solves all of these problems:
- ✅ Type-safe hooks with TypeScript inference
- ✅ Built-in parsers for common types
- ✅ Automatic URL synchronization
- ✅ SSR/SSG compatible
- ✅ Framework adapters (React, Next.js, Remix)
- ✅ Batched updates for multiple params
- ✅ Lightweight (~3kb gzipped)
How It Works
The @beztack/state package provides a centralized export of nuqs hooks and utilities:
import { useQueryState, parseAsInteger } from '@beztack/state';
function MyComponent() {
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1)
);
return (
<div>
<p>Current page: {page}</p>
<button onClick={() => setPage(p => p + 1)}>
Next page
</button>
</div>
);
}The state is automatically synchronized with the URL: ?page=2
Setup
React SPA (Vite)
Wrap your app with the NuqsAdapter:
// apps/ui/src/main.tsx
import { NuqsAdapter } from '@beztack/state/adapters/react';
import { createRoot } from 'react-dom/client';
import App from './app';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<NuqsAdapter>
<App />
</NuqsAdapter>
</StrictMode>
);Next.js App Router
// app/layout.tsx
import { NuqsAdapter } from '@beztack/state/adapters/next/app';
export default function RootLayout({ children }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}Next.js Pages Router
// pages/_app.tsx
import { NuqsAdapter } from '@beztack/state/adapters/next/pages';
export default function App({ Component, pageProps }) {
return (
<NuqsAdapter>
<Component {...pageProps} />
</NuqsAdapter>
);
}Basic Usage
Single State
Use useQueryState for a single search param:
import { useQueryState, parseAsString } from '@beztack/state';
function SearchBox() {
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('')
);
return (
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
/>
);
}Multiple States
Use useQueryStates for batched updates:
import { useQueryStates, parseAsInteger, parseAsString } from '@beztack/state';
function Filters() {
const [filters, setFilters] = useQueryStates({
minPrice: parseAsInteger.withDefault(0),
maxPrice: parseAsInteger.withDefault(100),
category: parseAsString.withDefault(''),
});
// Update multiple params at once (single URL update)
const applyFilters = () => {
setFilters({
minPrice: 20,
maxPrice: 80,
category: 'electronics',
});
};
return (
<div>
<p>Price: {filters.minPrice} - {filters.maxPrice}</p>
<p>Category: {filters.category || 'All'}</p>
<button onClick={applyFilters}>Apply Filters</button>
</div>
);
}Built-in Parsers
nuqs provides type-safe parsers for common data types:
String
import { useQueryState, parseAsString } from '@beztack/state';
const [name, setName] = useQueryState(
'name',
parseAsString.withDefault('')
);Integer
import { useQueryState, parseAsInteger } from '@beztack/state';
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1)
);Float
import { useQueryState, parseAsFloat } from '@beztack/state';
const [price, setPrice] = useQueryState(
'price',
parseAsFloat.withDefault(0.0)
);Boolean
import { useQueryState, parseAsBoolean } from '@beztack/state';
const [enabled, setEnabled] = useQueryState(
'enabled',
parseAsBoolean.withDefault(false)
);Enum
import { useQueryState, parseAsStringEnum } from '@beztack/state';
const sortOptions = ['asc', 'desc', 'newest', 'oldest'] as const;
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum([...sortOptions]).withDefault('asc')
);Array
import { useQueryState, parseAsArrayOf, parseAsInteger } from '@beztack/state';
const [tags, setTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsInteger).withDefault([])
);JSON
import { useQueryState, parseAsJson } from '@beztack/state';
const [config, setConfig] = useQueryState(
'config',
parseAsJson<{ theme: string; lang: string }>().withDefault({
theme: 'dark',
lang: 'en',
})
);DateTime
import { useQueryState, parseAsIsoDateTime } from '@beztack/state';
const [date, setDate] = useQueryState(
'date',
parseAsIsoDateTime.withDefault(new Date())
);Advanced Patterns
Updater Functions
Update state based on previous value:
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0));
// Increment
<button onClick={() => setCount(c => c + 1)}>+1</button>
// Decrement
<button onClick={() => setCount(c => c - 1)}>-1</button>Clear State
Set to null to remove from URL:
<button onClick={() => setSearch(null)}>Clear</button>Shallow Routing
Prevent scroll to top on URL change:
const [tab, setTab] = useQueryState('tab', {
defaultValue: 'home',
shallow: true, // Don't scroll to top
});Server-Side Rendering
Access search params on the server:
import { createSearchParamsCache, parseAsInteger } from '@beztack/state/server';
// Define your parsers
const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(10),
});
// In your server component or loader
export async function loader({ request }) {
const searchParams = new URL(request.url).searchParams;
const { page, limit } = searchParamsCache.parse(searchParams);
const data = await fetchData({ page, limit });
return { data };
}Best Practices
Always Use Parsers
// ✅ Good - Type-safe with parser
const [count, setCount] = useQueryState(
'count',
parseAsInteger.withDefault(0)
);
// ❌ Bad - Returns string | null
const [count, setCount] = useQueryState('count');Provide Default Values
// ✅ Good - Never null
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('')
);
// ❌ Bad - Can be null
const [search, setSearch] = useQueryState('search');Use Batched Updates
// ✅ Good - Single URL update
const [filters, setFilters] = useQueryStates({
min: parseAsInteger.withDefault(0),
max: parseAsInteger.withDefault(100),
});
setFilters({ min: 10, max: 90 });
// ❌ Bad - Multiple URL updates
const [min, setMin] = useQueryState('min', parseAsInteger.withDefault(0));
const [max, setMax] = useQueryState('max', parseAsInteger.withDefault(100));
setMin(10);
setMax(90);Use Enums for Fixed Options
// ✅ Good - Type-safe options
const sortOptions = ['asc', 'desc'] as const;
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum([...sortOptions]).withDefault('asc')
);
// ❌ Bad - Any string allowed
const [sort, setSort] = useQueryState('sort', parseAsString.withDefault('asc'));Demo
Check out the live demo at /nuqs-demo in the UI app to see all features in action:
- String state with text input
- Number parser with pagination
- Boolean parser with switches
- Enum parser with options
- Array parser with tags
- Batched updates with filters
Resources
Troubleshooting
Invalid Hook Call Error
If you see "Invalid hook call" errors:
- Ensure
NuqsAdapteris wrapping your app - Check for multiple React versions (run
pnpm list react) - Verify Vite config has
dedupe: ["react", "react-dom"]
State Not Syncing with URL
- Make sure the adapter is at the root level
- Check that you're using the correct adapter for your framework
- Verify the parser is correctly configured
SSR Hydration Mismatch
Use server-side parsers to match client-side state:
import { createSearchParamsCache } from '@beztack/state/server';
const cache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
});
// Use in getServerSideProps or loader
const { page } = cache.parse(searchParams);