v1.0.0 · Now on npm

URL State,as simple asuseState.

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-sync
0 dependencies< 2KB gzipTypeScript nativeReact 18+App Router ready
filters.tsx
1import { 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 })
6× less code
localhost:3000?page=1|

URL updates in real-time · Back/Forward works · SSR-safe

✓ TypeScript native
✓ Zero dependencies
Built different

Why switch to next-query-sync?

Every feature is designed to eliminate boilerplate and ship faster — without sacrificing type safety or performance.

Killer feature #1

useState syntax.
No parser imports.

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.

number → parseAsIntegerboolean → parseAsBooleanstring → parseAsString
page.tsx
// ❌ 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)
Type-Safe by default

Zero casts.
Zero surprises.

TypeScript infers the exact return type from your default. withDefault narrows T | null to T at the type level.

Performance

Smart batching.
0 wasted renders.

queueMicrotask coalesces every param change in the same tick into one single replaceState call — no FPS drops.

SSR & App Router

Works on the server.
No hydration mismatch.

Every window access is guarded. useSyncExternalStore prevents tearing in Concurrent Mode.

Killer feature #2

Native Zod integration

Pass a Zod schema directly — the library auto-parses JSON, validates, and falls back to default on error. Works with v3 & v4.

const [filter, setFilter] = useQueryState(
'filter', MyZodSchema
)
Killer feature #3

Built-in debounce & transitions

Stop writing useDebounce wrappers. Built-in debounce + React 18 startTransition keeps the UI snappy.

const [q, setQ] = useQueryState('q', '', {
debounce: 300, startTransition: true
})
Feature comparisonvs the competition
Featurenuqsnext-query-paramsnext-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
9 interactive demos

Live Examples

useState-like auto-inference, Zod schema validation, debounce + startTransition, parser chaining, arrays, booleans and more — all with live URL syncing and runnable code.

Open examples
Reference

API Reference

Complete documentation for all exports.

Built-in Parsers

Ready-to-use parsers for common types. Pass any of these as the second argument to useQueryState.

ParserURL stringJS valueReturn type
parseAsString"hello""hello"string | null
parseAsInteger"42"42number | null
parseAsFloat"3.14"3.14number | null
parseAsBoolean"true" / "false"true / falseboolean | 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.

example.ts
1const pageParser = withDefault(parseAsInteger, 1)
2// pageParser.parse(null) → 1 (key absent from URL)
3// pageParser.parse('') → 1 (empty string)
4// pageParser.parse('5') → 5
5
6const [page, setPage] = useQueryState('page', pageParser)
7// page: number ← TypeScript knows this is never null

useQueryState(key, parser, options?)

Syncs a single URL search param with React state. SSR-safe, tearing-free.

ParamTypeDescription
keystringURL search param name
parserParser<T> | ParserWithDefault<T>Determines how to parse / serialize the value
options.history'push' | 'replace'Default: 'replace'. Use 'push' to create browser history entries
usage.tsx
1// With plain parser → value may be null
2const [search, setSearch] = useQueryState('q', parseAsString)
3// search: string | null
4
5// setSearch('hello') → ?q=hello
6// setSearch(null) → removes ?q from URL
7// setSearch(v => v + '!') → functional updater
8
9// With withDefault → value is never null
10const [page, setPage] = useQueryState(
11 'page',
12 withDefault(parseAsInteger, 1),
13 { history: 'push' }
14)
15// page: number

useQueryStates(schema, options?)

Syncs multiple URL params at once. All updates in one setValues call are coalesced into a single URL write.

usage.tsx
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 | null
8// params.tags → string[] | null
9
10// Single history entry even with multiple keys:
11setParams({ page: 2, search: 'react' })
12
13// Functional updater per key:
14setParams({ page: p => (p ?? 1) + 1 })

Custom Parsers

Implement the Parser<T> interface for any custom type. parse receives the raw string (or null when absent) and must return T | null.

parsers.ts
1import type { Parser } from "color:#a78bfa">'next-query-sync'
2
3// Custom parser for Date objects
4// URL: ?date=2024-03-15 ↔ JS: Date("2024-03-15")
5const parseAsDate: Parser<Date> = {
6 parse: (v) => {
7 if (!v) return null
8 const d = new Date(v)
9 return isNaN(d.getTime()) ? null : d
10 },
11 serialize: (d) => d.toISOString().split('T')[0]!,
12}
13
14// Usage:
15const [date, setDate] = useQueryState('date', parseAsDate)
16// date: Date | null

How It Works

Three key design decisions that make it fast and safe.

Microtask Batching

Every setValue call enqueues a microtask. Multiple synchronous calls collapse into one history.replaceState / pushState — zero redundant re-renders.

SSR Safety

Every window access is guarded by typeof window === 'undefined'. getServerSnapshot always returns null — no hydration mismatches.

useSyncExternalStore

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)

Start in 30 seconds

Install the package, pick a parser, wrap with withDefault if you need a non-nullable value, done.

1. Install

npm i next-query-sync

2. Import

import { useQueryState, parseAsInteger, withDefault } from 'next-query-sync'

3. Use

const [page, setPage] = useQueryState('page', withDefault(parseAsInteger, 1))