Search params, cookies, localStorage and sessionStorage all do the same job: they store small key-value state. But each has its own stringly-typed API, so every project ends up with the same glue code written by hand:
// search params
const raw = searchParams.get('page')
const page = raw && !Number.isNaN(+raw) ? +raw : 1
// localStorage
const raw = localStorage.getItem('settings')
const settings = raw ? JSON.parse(raw) : { theme: 'light' }
// cookies
const consent = document.cookie
.split('; ')
.find(c => c.startsWith('consent='))
?.split('=')[1] === 'true'
None of this is typed or reactive. Then come the edge cases: validating bad input, wrapping JSON.parse in try/catch, listening to storage events so tabs stay in sync, merging defaults into partial objects...
This tutorial shows how to replace all of that with one API using kvant, a type-safe state manager for key-value interfaces. You'll bind a key, describe the value with a schema once, and read and write it like useState. The examples use React and Next.js, but the same pattern works in Vue, Vue Router and Nuxt (covered at the end).
Setup
npm install kvantjs
The package has three parts you'll import from:
- A framework adapter, like
kvantjs/nextorkvantjs/react-router, which provides the hooks -
kvantjs/reactfor storage and cookie hooks that work in any React app -
kvantjs/schema(aliased askvbelow), a Zod-flavored schema builder that handles parsing and serialization
URL search params
Start with the classic case: a search box synced to ?q=. Bind the key q with a string schema and a default:
import { useSearchParams } from 'kvantjs/next'
import * as kv from 'kvantjs/schema'
function SearchInput() {
const [query, setQuery] = useSearchParams('q', kv.string().default(''))
// ^? string
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
)
}
With ?q=hello in the URL, query is 'hello'. Calling setQuery writes back to the URL, and every component bound to q re-renders. The URL is the single source of truth, so state survives reloads and shared links.
Notice what the schema gives you for free. The type flows from kv.string() into the hook, so query is string, not string | null. The default '' stays internal: when the value equals the default, the param is removed from the URL instead of showing ?q=.
Numbers, enums, booleans and arrays work the same way:
const [page, setPage] = useSearchParams('page', kv.index().max(20).default(0))
// ^? number
const [sort, setSort] = useSearchParams('sort', kv.enum(['asc', 'desc']).default('asc'))
// ^? "asc" | "desc"
kv.index() parses a 1-based page number into a 0-based index. If someone hand-edits the URL to ?page=banana, parsing fails and the hook falls back to the default. Schemas never throw on bad input, which is exactly what you want for data strangers can type into a URL bar.
Binding multiple keys at once
Pages with filters usually need several params. Bind a whole key map in one call and updates batch into a single write:
const [filters, setFilters] = useSearchParams({
q: kv.string().default(''),
page: kv.index().default(0),
sort: kv.enum(['asc', 'desc']).default('asc'),
tags: kv.array(kv.string()).default([]), // repeated params: ?tags=a&tags=b
})
setFilters(prev => ({ ...prev, page: prev.page + 1 }))
History and routing options
Pass options as the last argument. The two you'll reach for most are history and shallow:
const [query, setQuery] = useSearchParams('q', kv.string().default(''), {
history: 'push', // add browser history entries instead of replacing
shallow: false, // go through the Next.js router, re-running server components
})
To set options once for a subtree, wrap it in the options provider:
import { SearchParamsOptionsProvider } from 'kvantjs/next'
<SearchParamsOptionsProvider defaultOptions={{ history: 'push' }}>
{children}
</SearchParamsOptionsProvider>
localStorage and sessionStorage
The same pattern moves to web storage with no new concepts. Swap the hook, keep the schema:
import { useLocalStorage, useSessionStorage } from 'kvantjs/react'
// persists across reloads, syncs across tabs via storage events
const [theme, setTheme] = useLocalStorage('theme', kv.enum(['light', 'dark']).default('light'))
// scoped to the current tab
const [draft, setDraft] = useSessionStorage('draft', kv.string().default(''))
This is where the schema layer earns its keep compared to the usual JSON.parse approach. kv.enum(['light', 'dark']) doesn't just deserialize the stored value, it validates it. If a future version of your app removes the 'dark' option, old stored values fail parsing and fall back to the default instead of crashing your theme switcher.
Cookies
Cookies are the third leg. They're readable by the server and respect Set-Cookie attributes, which you pass as options:
import { useCookies } from 'kvantjs/react'
const [consent, setConsent] = useCookies(
// ^? boolean
'consent',
kv.stringbool().default(false),
{ maxAge: 60 * 60 * 24 * 365 },
)
kv.stringbool() handles the classic cookie problem where everything, including booleans, arrives as a string. For server-side rendering there's a bit of extra setup so the cookie value reaches the server, the cookies guide walks through it.
Storing objects: composing schemas
Simple scalars cover most cases, but sometimes you want a whole object in one key. Say, a settings blob in the URL, base64-encoded so it stays compact and shareable. Normally that's an afternoon of parsing and encoding code. With kvant you compose schemas instead:
const settingsSchema = kv.base64url()
.pipe(
kv.json(
kv.object({
theme: kv.enum(['light', 'dark']).default('light'),
fontSize: kv.number().default(16)
})
).prefault('{}')
)
// '?settings=eyJ0aGVtZSI6ImRhcmsifQ' <-> '{"theme":"dark"}' <-> { theme: 'dark', fontSize: 16 }
const [settings, setSettings] = useSearchParams('settings', settingsSchema)
// ^? { theme: "light" | "dark"; fontSize: number }
setSettings({ theme: 'light', fontSize: 16 }) // matches the defaults, so the param leaves the URL
Each layer does one job: kv.base64url() handles the encoding, kv.json() the JSON round-trip, kv.object() the shape and defaults. Serialization is lossless and pure, so what goes in is exactly what comes back out.
Using it from Vue
Everything above has a Vue counterpart with an idiomatic ref-based API. It works with plain Vue, Vue Router and Nuxt:
<script setup lang="ts">
import { useSearchParams } from 'kvantjs/vue-router'
import * as kv from 'kvantjs/schema'
const page = useSearchParams('page', kv.index().default(0))
</script>
One schema API across both ecosystems. If your team runs React in one product and Vue in another, that's one less thing to relearn.
Wrapping up
The pattern to remember is hook, key, schema, options. The schema defines the type and the validation, the interface (URL, storage, cookie) stays the source of truth, and defaults never leak into the URL. Invalid input falls back to defaults instead of throwing, which matters a lot for anything a user can hand-edit.
Full docs and live examples are at kvantjs.dev, with per-framework docs for React, Next.js, React Router, Vue, Vue Router and Nuxt. Source is on GitHub under MIT. If you're coming from nuqs, the URL parts will feel familiar: kvant takes the same ideas and extends them to every key-value interface, with schemas in place of standalone parsers.
Top comments (0)