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);