DEV Community

Alex
Alex

Posted on

How I Built state-in-url: My Journey Turning the URL Bar into Real React State

state-in-url demo gif

I'm the developer behind state-in-url. The library lets you put real JavaScript values - numbers, Date objects, nested arrays, objects - directly into browser query parameters. It keeps full TypeScript support throughout. The hook behaves exactly like useState, but the state lives in the URL. It survives reloads. You can share the link and nothing gets lost.

Try the live demo at https://state-in-url.dev

Type in the form. Watch the URL change instantly. Refresh the page. The data stays exactly as you left it. The example sits right in the repo too.

This project grew from a frustration I hit again and again while working on dashboards and larger forms. State ended up split across local variables, a store, and the URL for sharing. Other libraries came close, yet they always added too much setup or turned everything into strings and dropped my types. I decided to build the version I actually wanted to use every day.

The repo now sits at 400+ stars on GitHub. Version 6.1.2 shipped a few weeks ago. It still surprises me that people use it. Here is the full story - the frustrations, the choices that worked, the testing struggles, and what stayed with me afterward.

The Problem

You have probably faced this situation. A page holds filters or a detailed form. State lives in three places at once. Local component values. Maybe a store. Plus the URL so users can bookmark or share their exact view. Syncing it all felt like constant extra work.

I wanted the hook to stay dead simple. Return state and a setter, just like useState, but with TypeScript awareness of the exact shape. Real data types that survive the URL round trip. Clean support for Next.js App Router with SSR, plus React Router and Remix. No interference with other query parameters. And keep the whole thing tiny - zero runtime dependencies, under 2 KB gzipped.
That became my starting point in spare time.

The solution

The choice I remain happiest about is straightforward. I used one plain JavaScript object for both default values and the type definition. Without separate schema or heavy generics. You write code like this

export const formState = {
 name: '',
 age: undefined as number | undefined,
 agree_to_terms: false,
 tags: [] as { id: string; value: { text: string; time: Date } }[],
} as const;

type FormState = typeof formState;
Enter fullscreen mode Exit fullscreen mode

Pass that object to the hook and everything flows. Types and defaults work out of the box.
For shared state with useSharedState, I relied on a single JavaScript object. Components subscribe to changes, without React Context. It handled cases where unrelated parts of the app needed the same data without added complexity.
The encoder and decoder required some tuning. The goal was a readable URL that still preserved real types. Encoding a larger object takes roughly a millisecond on my machine.

Building the Library and the Framework Challenges

I structured the project as a pnpm monorepo. TypeScript dominates the codebase. Core logic lives in one package. Thin adapters cover Next.js, React Router, and Remix. Rollup manages the build. Nothing overly complicated.
Next.js App Router created the biggest difficulties. The way searchParams moves between server and client components caused repeated issues. I added a small header workaround to carry data through on the server side. Hydration stayed intact. Remix and React Router presented fewer obstacles, yet I still needed to handle history modes, push versus replace, and debounce settings so the URL stayed calm during typing.

Here is a real section from the Next.js 15 demo that runs live at state-in-url.dev

'use client'
import { useUrlState } from 'state-in-url/next';
import { formState } from './formState';

function Form() {
   const { urlState, setUrl, setState } = useUrlState(formState, { searchParams });
    return (
     <input
       value={urlState.name}
       onChange={(e) => setState(curr => ({ curr, name: e.target.value }))}
       onBlur={() => setUrl()} // update URL on blur for smoother typing
     />
     {/* tags, dates, checkboxes - all typed correctly */}
   );
}
Enter fullscreen mode Exit fullscreen mode

The code stays simple. It is just state that happens to live in the URL.

Testing was hard but important

I needed the library to feel reliable across frameworks and browsers. Real example apps for Next.js 14+, React Router v6+, and Remix live in the repo. Vitest covers the encoder and decoder basics. More real world tests cases covered with Playwright running full end-to-end tests in Chrome, Firefox, and Safari.

A fix in one place often broke something elsewhere. The commit message "Tests finally passing on Next.js 15" appeared more times than I care to count. It took quite few attempts adjusting Playwright configurations and the CI matrix.
GitHub Actions now runs linting, building, unit tests, and the complete browser suite on every push and PR. Semantic-release automates version bumps, changelog updates, GitHub releases, and npm publishing. Husky keeps commits tidy. Once the pipeline ran smoothly, maintaining the library stopped feeling like a burden.

Launching

I published to npm. The README received every recipe I could gather - debounced updates, resetting to defaults, multiple independent state objects on one page. I linked the live demo and waited.
The first stars arrived and felt surprisingly satisfying. Issues opened, mostly kind ones. Solid CI let me ship fixes quickly. I wrote the honest limits in a separate Limits.md file. URLs hold only so much - around 12 KB stays practical. Readers seemed to value the directness.

Lessons

  • Making the API match useState exactly shaped every other decision and made the library pleasant to use.
  • That single object for shape and defaults removed a lot of complexity.
  • Testing inside real frameworks felt painful yet essential - skipping it would have limited adoption.
  • Automating releases and chores with semantic-release and GitHub Actions turned maintenance into something manageable.
  • Keeping the bundle tiny happened on purpose. I watched the size and avoided dependencies deliberately. Would I change anything today? I might add Svelte or Astro support earlier. Hash routing could be interesting. The roadmap still has room to grow.

If this sound interesting give it a try

pnpm add state-in-url
Enter fullscreen mode Exit fullscreen mode

The demo lives at state-in-url.dev. The repository sits here. Star it if the approach fits your work.

I would enjoy hearing about the URL-state problems you encounter. Leave a comment. Open an issue. Send a pull request. Sharing the process publicly became one of the most rewarding aspects.
Happy coding, MIT license, of course. Build something useful with it.

Top comments (0)