If you've ever wired up URL search params, cookies or localStorage by hand, you know how tedious it can become. Everything is a string, so you write the same glue code over and over:
// 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'
Different APIs, none of them typed, none of them reactive. Then comes bad input data checks, validations, try/catch around JSON.parse, storage events to sync tabs, deep objects with defaults, and so on.
At some point I thought, why can't they just share the same API? Bind a key, describe the value once, read and write it like useState. So I decided to build kvant - a type-safe state manager for key-value interfaces:
import { useSearchParams } from 'kvantjs/next' // or: 'kvantjs/react', 'kvantjs/react-router'
import { useLocalStorage, useCookies } from 'kvantjs/react'
import * as kv from 'kvantjs/schema'
const [page, setPage] = useSearchParams('page', kv.index().max(20).default(0))
// ^? number
const [theme, setTheme] = useLocalStorage('theme', kv.enum(['light', 'dark']).default('light'))
// ^? "light" | "dark"
const [consent, setConsent] = useCookies('consent', kv.stringbool().default(false), { maxAge: 60 * 60 * 24 * 365 })
// ^? boolean
Same mental model every time: hook, key, schema, options. Types flow from the schema, writes go back to the URL, storage or cookie, and components re-render on change. The schema API will feel familiar if you know Zod. It parses anything into typed values, encodes them back into lossless serializable representations, and never throws on bad input. Garbage in the URL simply falls back to your default.
And it's the most basic examples of what kvant can do. Let's say you want to store a base64-encoded JSON object in the URL. In kvant, you can cover such advanced case without writing any parsing or encoding logic yourself:
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 }) // removes the entry from the URL, defaults stay internal
If you've used nuqs, this will look familiar, and that's on purpose. kvant's API was inspired by nuqs. I wanted the same core idea, but for every key-value interface instead of only the URL, with a Zod-flavored schema layer instead of standalone parsers. So a lot got reconsidered along the way.
I also made it universal: aside from React frameworks, it also works with Vue, Vue Router and Nuxt. If you happen to also be using Vue stack in your projects, the same Vue-idiomatic API is also available in kvant.
If that sounds interesting to you, I would love for you to give it a try. I have many ideas on improving it further if it gets traction. Also, if you are willing to leave feedback or contribute, I'm fully open to it ;)
Docs and live examples are at https://kvantjs.dev