Typed state, living in the URL

useUrlState is React state that writes itself to the query string. Objects, arrays and dates keep their types, every state is a shareable link, and it survives reloads — no providers, no boilerplate.

  • ~2 KB gzipped
  • zero dependencies
  • TypeScript-first
  • Next.js / react-router / Remix
  • MIT
npm i state-in-url

useUrlState — live with next.js

Type below — watch the URL light up

First client component
Other client component

Reads from URL — no props, no context, types and structure are preserved

{
name: string
age: undefinedundefined
agree_to_terms: falseboolean
tags: []
}

Same API, three routers

Quick start

1. Define the state
state
export const form: Form = {
  name: '',
  age: undefined,
  agree_to_terms: false,
  tags: [],
};

// use `Type` not `Interface`!
type Form = {
  name: string;
  age?: number;
  agree_to_terms: boolean;
  tags: { id: string; value: { text: string; time: Date } }[];
};
2. Use it in any components
ComponentA
'use client';

import { useUrlState } from 'state-in-url/next';
import { form } from './form';

export const ComponentA = () => {
  // see docs for all possible params https://github.com/asmyshlyaev177/state-in-url/tree/master/packages/urlstate/next/useUrlState
  // useHistory: false to update sp on server component
  const { urlState, setState, setUrl } = useUrlState(form, { useHistory: true }); 

  return <>
    <input
      id="name"
      value={urlState.name} 
      onChange={(ev) => setUrl({ name: ev.target.value })}
      />
    // OR can update state immediately but sync change to url as needed
    <input
      value={urlState.name}
      onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
      onBlur={() => setUrl()}
    />
    <button onClick={() => setUrl((curr, initial) => initial)}>
      Reset
    </button>
    </>
};
ComponentB
'use client';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';

// "searchParams" used to pass params from Server Components
export const ComponentB = ({ searchParams }: { searchParams?: object }) => {
  const { urlState } = useUrlState(form, { searchParams });

// will be defaultValue from `form` if not in url, no need to check

  return <div>name: {urlState.name}</div>
};
3. Create a reusable hook for a slice of state
useFormState - custom hook
'use client';

import React from 'react';
import { useUrlState } from 'state-in-url/next';

const form: Form={
  name: '',
  age: undefined,
  agree_to_terms: false,
  tags: [],
};

type Form = {
  name: string;
  age?: number;
  agree_to_terms: boolean;
  tags: {id: string; value: {text: string; time: Date } }[];
};

export const useFormState = ({ searchParams }: { searchParams?: object }) => {
  const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, {
    searchParams,
  });

  // first navigation will push new history entry
  // all following will just replace that entry
  // this way will have history with only 2 entries - ['/url', '/url?key=param']

  const replace = React.useRef(false);
  const setUrl = React.useCallback((
      state: Parameters<typeof setUrlBase>[0],
      opts?: Parameters<typeof setUrlBase>[1]
    ) => {
      setUrlBase(state, { replace: replace.current, ...opts });
      replace.current = true;
  }, [setUrlBase]);

  return { urlState, setUrl, resetUrl: reset };
};

Using an AI coding agent?

Agents get the same two things wrong here, every time. They type the state shape with interface, which the JSONCompatible constraint rejects outright. And they build the default-state object inside the component, which breaks sharing silently — it is keyed by object identity, so nothing errors, the two components simply stop seeing each other.

So the package ships six SKILL.md files. Your agent loads one on demand through TanStack Intent, and they are versioned with the library rather than with this page.

npx @tanstack/intent@latest install

Run once in a project that already has state-in-url installed. Your agent then finds the skills in node_modules/state-in-url/skills/.

  • feature-state-hookDefining state, and wrapping useUrlState in a feature-scoped hook
  • input-handlingText inputs, sliders, anything that changes fast
  • nextjs-ssrApp Router: searchParams forwarding, Proxy for layouts
  • react-router-remix-setupReact Router v6/v7 or Remix v2 setup
  • form-library-integrationPairing with react-hook-form (or formik)
  • shared-state-no-urluseSharedState — sharing without touching the URL

The sources are on GitHub. An agent that can't load Intent skills should read llms.txt instead — the same rules, condensed into one file.

Why state-in-url?

URL state libraries exist, but most are either cumbersome to set up or limited in what they can store. state-in-url aims to be the one that just works: an API that mirrors React.useState, with the URL as the store.

Store state without boilerplate, build deep links, and share data between unrelated client components — no provider needed. Structure and types are preserved end to end: a Date goes in, a Date comes out.

Built test-first, with unit and cross-browser e2e suites running on every commit.

Next.js: no Suspense boundary

The hook never calls useSearchParams, so a component using it doesn't need wrapping in Suspense and doesn't opt its page out of prerendering — PPR and cacheComponents included. It reads the URL directly and follows every later change, including a history.pushState from code that knows nothing about it.

Not on Next.js or react-router?

The encodeState / decodeState helpers work with any framework or plain JS — the hooks are a convenience on top.

Check out the GitHub page — a star goes a long way.

Share it with other devs

Uneed Embed Badge