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 / Astro
  • MIT
npm i state-in-url

useUrlState — live with astro

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, four frameworks

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. Wrap it in a reusable hook
useFormState
import { useUrlState } from 'state-in-url/astro';
import { form } from './form';

// One hook per feature - the whole API for this state.
// searchParams is the island prop: with it the server render matches the URL
export const useFormState = (searchParams?: Record<string, string>) =>
  useUrlState(form, { searchParams });
3. Use it in any components — they all share it
index.astro
---
import { ComponentA } from '../components/ComponentA';
import { ComponentB } from '../components/ComponentB';

// A plain object: island props are serialized, URLSearchParams is not
const searchParams = Object.fromEntries(Astro.url.searchParams);
---

<ComponentA client:load searchParams={searchParams} />
<ComponentB client:load searchParams={searchParams} />
ComponentA
import { useFormState } from './useFormState';

export const ComponentA = ({ searchParams }: { searchParams?: Record<string, string> }) => {
  // see docs for all possible params https://github.com/asmyshlyaev177/state-in-url/tree/master/packages/urlstate/astro/useUrlState
  const { urlState, setUrl, setState } = useFormState(searchParams);

  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
import { useFormState } from './useFormState';

export const ComponentB = ({ searchParams }: { searchParams?: Record<string, string> }) => {
  // same state as ComponentA - a separate island, no props between them, no context
  const { urlState } = useFormState(searchParams);

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

  return <div>name: {urlState.name}</div>
};
4. Grow the hook when you need more
useFormState - extended
import React from 'react';
import { useUrlState } from 'state-in-url/astro';
import { form } from './form';

export const useFormState = (searchParams?: Record<string, string>) => {
  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 };
};

state-in-url vs nuqs

Searching for a nuqs alternative? Both keep typed state in the query string; they differ in how much you set up and what a value can be.

Whatstate-in-urlnuqs
SetupNext.js, React Router v6/v7, Remix, Astro, plain JS helpersAdapter component wraps the app
State shapeOne typed object, like React.useStatePer-key values, a parser declared for each
Reuse across componentsWrap the hook once — every component shares the state, no propsExtract your own hook around the parser map
Nested objects and arraysBuilt in — structure and types preservedJSON parser plus your own runtime validator
DatesPreserved automaticallyBuilt-in parser, declared per key
Size, full import~2.9 KB gzipped~6.7 KB gzipped
Runtime dependenciesNoneOne
RoutersNext.js, React Router v6/v7, Remix, plain JS helpersNext.js, React Router, Remix, TanStack Router, plain React

Sizes: whole-library import, esbuild minify + gzip, measured August 2026 against nuqs 2.10.1.

nuqs is a fine library — reach for it when you want each value as its own readable query param, or you are on TanStack Router. Reach for state-in-url when you want a whole typed object in the URL with zero setup.

Read the full comparison — same feature built in both, and how to migrate

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