Migrating to nuqs
URL state management is one of those things that seems so simple to just read and write query parameters. In a recent project, I found myself wrestling with React Router's useSearchParams hook more and more. State synchronization issues, type safety concerns, and a lack luster developer experience were becoming regular obstacles.
This is the story of how I migrated from React Router's useSearchParams to nuqs, a type-safe search params state manager for React, and what I learned along the way.
The Problem with useSearchParams
Before diving into the solution, let's understand what we were dealing with. React Router's useSearchParams hook follows a pattern similar to useState:
const [searchParams, setSearchParams] = useSearchParams();
const slotId = searchParams.get('slotId');
setSearchParams({ slotId: '123' });
While this works, I encountered several pain points:
1. No Type Safety
Every value retrieved from searchParams.get() returns string | null. This meant constant manual parsing and type checking:
const pageIdString = searchParams.get('id');
const pageId = pageIdString ? parseInt(pageIdString, 10) : null;
if (isNaN(pageId)) {
// Handle invalid input
}
2. Asynchronous Updates Without Promises
setSearchParams updates are asynchronous but don't return a promise. This made it impossible to await URL changes, leading to race conditions:
// This doesn't work as expected
setSearchParams({ pageId: '123' });
// The URL might not be updated yet!
clickOnElement(); // Might fail because URL isn't ready
Enter nuqs
nuqs is a library that treats URL search parameters as React state, with some major improvements:
- Type-safe by default - Built-in parsers for common types
- Promise-based updates - Await URL changes
- Automatic serialization - No manual parsing needed
- Framework agnostic - Works with React Router, Next.js, etc.
- Small bundle size - ~3.5kb gzipped
Create Custom Hooks
I created dedicated hooks for each query parameter we used throughout the app. This gave me a centralized place to define types and parsing logic:
// hooks/usePageId.ts
import { parseAsInteger, useQueryState } from "nuqs";
export const usePageIdFromUrl = () => {
return useQueryState("pageId", parseAsInteger);
};
This way, I abstract away all of the custom URL logic into a reusable hook. Yes, you could do the same with useSearchParams but now you get the added bonus of the type safety.
Replace useSearchParams Calls
The actual migration was straightforward. Here's a before/after comparison:
Before:
import { useSearchParams } from 'react-router-dom';
const Component = () => {
const [searchParams, setSearchParams] = useSearchParams();
const pageIdString = searchParams.get('pageId');
const pageId = pageIdString ? parseInt(pageIdString, 10) : null;
const openDialog = (id: number) => {
setSearchParams({ pageId: id.toString() });
};
return <button onClick={() => openDialog(123)}>Open</button>;
};
After:
import { usePageIdFromUrl } from 'hooks/usePageIdFromUrl';
const Component = () => {
const [pageId, setPageId] = usePageIdFromUrl();
const openDialog = async (id: number) => {
await setPageId(id);
};
return <button onClick={() => openDialog(123)}>Open</button>;
};
Notice:
- ✅ No manual parsing
- ✅ Type-safe
slotId(number | null) - ✅ Can await the update
- ✅ Cleaner, more readable code
Handle Async Updates
One of the biggest improvements was properly handling async updates. Previously, I was seeing race conditions where I'd update the URL and immediately perform an action that depended on the URL being updated.
The Problem (Before):
const doSomething = () => {
// ...do some business logic
// Clear slotId URL param when closing dialog
void setPageId(null); // ⚠️ Not awaited!
// Other code that might depend on URL being cleared...
};
The Solution (After):
const doSomething = async () => {
// ...do some business logic
// Clear slotId URL param when closing dialog
await setPageId(null); // ✅ Properly awaited!
// URL is guaranteed to be updated now
};
Add ESLint Rules
To prevent future regressions, I added ESLint rules to catch any usage of useSearchParams:
// eslint.config.mjs
{
"no-restricted-syntax": [
"error",
{
selector: "ImportDeclaration[source.value='react-router-dom'] ImportSpecifier[imported.name='useSearchParams']",
message: "Avoid using `useSearchParams` from react-router-dom. Consider using `useQueryState` from nuqs."
},
{
selector: "CallExpression[callee.name='useSearchParams']",
message: "Avoid calling `useSearchParams` from react-router-dom. Use `useQueryState` from nuqs instead."
}
]
}
Now if anyone tries to use useSearchParams, they'll get a helpful error message pointing them to nuqs, as well as failing linting to catch the error before it gets merged.
Pros and Cons
Pros ✅
Type Safety Out of the Box
- Built-in parsers:
parseAsInteger,parseAsString,parseAsBoolean,parseAsStringLiteral, etc. - Custom parsers are easy to create
- TypeScript knows the exact type without manual casting
- Built-in parsers:
Promise-Based Updates
- Can await URL changes
- Eliminates race conditions
- Better control flow in async operations
Automatic Serialization
- No more manual
toString()andparseInt() - Handles arrays, objects, and custom types
- Consistent serialization logic
- No more manual
Better Developer Experience
- Simpler API surface
- Less boilerplate
- Easier to test
Cons ❌
Additional Dependency
- Adds ~3.5kb to bundle (minimal, but worth noting)
- Another library to maintain and update
Learning Curve
- Team needs to learn new API
- Different mental model from
useSearchParams - Need to understand parsers and serializers
Migration Effort
- Required touching many files
- Need to update tests
- Risk of introducing bugs during migration
Would I Do It Again?
Absolutely yes. Despite the migration effort, the benefits far outweigh the costs:
- ✅ Better type safety caught bugs at compile time
- ✅ Async/await pattern eliminated race conditions
- ✅ Code is cleaner and more maintainable
- ✅ Developer experience is significantly better
Conclusion
Migrating from useSearchParams to nuqs was one of those refactors that you don't realize you need until you do it. What started as "let's add some type safety" turned into "wow, I've been fighting the wrong abstractions this whole time."
The key takeaways:
- Type safety matters - Even for simple query parameters
- Async primitives are good - Promises are better than void operations
- Good abstractions scale - nuqs grows with your needs
- Migration effort pays off - Better DX = fewer bugs = happier developers
If you're building a React application with non-trivial URL state management, I highly recommend giving nuqs a try. Start with one or two query parameters, create wrapper hooks, and see how it feels.