Drop-in URL sync for React & Next.js. Type inferred from default value — no parsers to import, no boilerplate to write.
npm i next-query-sync1import { useQueryState } from "color:#a78bfa">'next-query-sync'2 3// Type inferred from default value ✨4const [page, setPage] = useQueryState('page', 1)5const [q, setQ ] = useQueryState('q', '')6const [open, setOpen] = useQueryState('modal', false)7 8// Also supports Zod, custom parsers & debounce:9const [filter, setFilter] = useQueryState('f', MyZodSchema)10const [search, setSearch] = useQueryState('q', '', { debounce: 300 })URL updates in real-time · Back/Forward works · SSR-safe
Every feature is designed to eliminate boilerplate and ship faster — without sacrificing type safety or performance.
Pass a primitive default and the library infers the type, picks the right parser, and serializes back automatically. 90% of real apps need nothing else.
// ❌ nuqs — import every parser manually
import { parseAsInteger, parseAsString, parseAsBoolean } from 'nuqs'
useQueryState('page', parseAsInteger.withDefault(1))
// ✅ next-query-sync — just like useState
const [page, setPage] = useQueryState('page', 1)
const [q, setQ ] = useQueryState('q', '')
const [open, setOpen] = useQueryState('modal', false)TypeScript infers the exact return type from your default. withDefault narrows T | null to T at the type level.
queueMicrotask coalesces every param change in the same tick into one single replaceState call — no FPS drops.
Every window access is guarded. useSyncExternalStore prevents tearing in Concurrent Mode.
Pass a Zod schema directly — the library auto-parses JSON, validates, and falls back to default on error. Works with v3 & v4.
Stop writing useDebounce wrappers. Built-in debounce + React 18 startTransition keeps the UI snappy.
| Feature | nuqs | next-query-params | next-query-sync |
|---|---|---|---|
| useState-like syntax (auto-inference) | ❌ | ❌ | ✅ |
| Zod schema support (1 line) | ❌ | ❌ | ✅ |
| Built-in debounce + startTransition | ❌ | ❌ | ✅ |
| React 18 useSyncExternalStore | ✅ | ❌ | ✅ |
| SSR & App Router safe | ✅ | ⚠️ | ✅ |
| Smart param batching | ✅ | ❌ | ✅ |
| Bundle size (gzip) | ~7KB | ~5KB | < 2KB |
useState-like auto-inference, Zod schema validation, debounce + startTransition, parser chaining, arrays, booleans and more — all with live URL syncing and runnable code.
Complete documentation for all exports.
Ready-to-use parsers for common types. Pass any of these as the second argument to useQueryState.
| Parser | URL string | JS value | Return type |
|---|---|---|---|
parseAsString | "hello" | "hello" | string | null |
parseAsInteger | "42" | 42 | number | null |
parseAsFloat | "3.14" | 3.14 | number | null |
parseAsBoolean | "true" / "false" | true / false | boolean | null |
parseAsArrayOf(p) | "a,b,c" | ["a","b","c"] | T[] | null |
withDefault(parser, defaultValue)Wraps any parser so missing / unparseable values return defaultValue instead of null. TypeScript narrows the return type from T | null to T.
1const pageParser = withDefault(parseAsInteger, 1)2// pageParser.parse(null) → 1 (key absent from URL)3// pageParser.parse('') → 1 (empty string)4// pageParser.parse('5') → 556const [page, setPage] = useQueryState('page', pageParser)7// page: number ← TypeScript knows this is never nulluseQueryState(key, parser, options?)Syncs a single URL search param with React state. SSR-safe, tearing-free.
| Param | Type | Description |
|---|---|---|
key | string | URL search param name |
parser | Parser<T> | ParserWithDefault<T> | Determines how to parse / serialize the value |
options.history | 'push' | 'replace' | Default: 'replace'. Use 'push' to create browser history entries |
1// With plain parser → value may be null2const [search, setSearch] = useQueryState('q', parseAsString)3// search: string | null45// setSearch('hello') → ?q=hello6// setSearch(null) → removes ?q from URL7// setSearch(v => v + '!') → functional updater89// With withDefault → value is never null10const [page, setPage] = useQueryState(11 'page',12 withDefault(parseAsInteger, 1),13 { history: 'push' }14)15// page: numberuseQueryStates(schema, options?)Syncs multiple URL params at once. All updates in one setValues call are coalesced into a single URL write.
1const [params, setParams] = useQueryStates({2 page: withDefault(parseAsInteger, 1),3 search: parseAsString,4 tags: parseAsArrayOf(parseAsString),5})6// params.page → number (never null)7// params.search → string | null8// params.tags → string[] | null910// Single history entry even with multiple keys:11setParams({ page: 2, search: 'react' })1213// Functional updater per key:14setParams({ page: p => (p ?? 1) + 1 })Implement the Parser<T> interface for any custom type. parse receives the raw string (or null when absent) and must return T | null.
1import type { Parser } from "color:#a78bfa">'next-query-sync'23// Custom parser for Date objects4// URL: ?date=2024-03-15 ↔ JS: Date("2024-03-15")5const parseAsDate: Parser<Date> = {6 parse: (v) => {7 if (!v) return null8 const d = new Date(v)9 return isNaN(d.getTime()) ? null : d10 },11 serialize: (d) => d.toISOString().split('T')[0]!,12}1314// Usage:15const [date, setDate] = useQueryState('date', parseAsDate)16// date: Date | nullThree key design decisions that make it fast and safe.
Every setValue call enqueues a microtask. Multiple synchronous calls collapse into one history.replaceState / pushState — zero redundant re-renders.
Every window access is guarded by typeof window === 'undefined'. getServerSnapshot always returns null — no hydration mismatches.
React 18 Concurrent Mode guarantee. All components reading the same param see an identical snapshot — no tearing when React interrupts and restarts a render.
Batching flow
Event handler
setPage(2) → scheduleUrlUpdate('page', '2') ─┐
setSearch('q') → scheduleUrlUpdate('search', 'q') ─┤ same microtask batch
setTags(['a']) → scheduleUrlUpdate('tags', 'a') ─┘
↓
history.replaceState(…?page=2&search=q&tags=a)Install the package, pick a parser, wrap with withDefault if you need a non-nullable value, done.
1. Install
npm i next-query-sync2. Import
import { useQueryState, parseAsInteger, withDefault } from 'next-query-sync'3. Use
const [page, setPage] = useQueryState('page', withDefault(parseAsInteger, 1))