# Connect state to the URL

Use URL persistence when you want a copied link to restore store data. In this browser-only React example, clicking **Add a fish** updates the count and the URL hash. Opening the copied URL in a new tab restores that count. You can switch the same store to query parameters with a `localStorage` fallback.

Keep the [`create`](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/zustand#create), [`persist`](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/zustand-middleware#persist), and [`createJSONStorage`](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/zustand-middleware#createjsonstorage) setup, but replace its storage with a URL-backed [`StateStorage`](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/zustand-middleware#statestorage) adapter; see [Persist store data](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/persist-store-data) for the persistence setup.

## 1. Create hash storage

Add this file to your React project. `getItem` returns the stored string, or `null` when the key is absent. `setItem` updates one hash parameter, and `removeItem` deletes it; both preserve other hash parameters.

```ts title="hash-storage.ts"
import type { StateStorage } from 'zustand/middleware'

export const urlStorage: StateStorage<void> = {
  getItem: (key) => {
    const params = new URLSearchParams(window.location.hash.slice(1))
    return params.get(key)
  },
  setItem: (key, value) => {
    const params = new URLSearchParams(window.location.hash.slice(1))
    params.set(key, value)
    window.location.hash = params.toString()
  },
  removeItem: (key) => {
    const params = new URLSearchParams(window.location.hash.slice(1))
    params.delete(key)
    window.location.hash = params.toString()
  },
}
```

Treat the hash as a parameter list, not as a route or an element anchor.

## 2. Connect the adapter to a typed store

Use `food-storage` as the URL parameter name and share only `fishes`; see [Type stores and middleware](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/type-stores-and-middleware) for typing `storage` with [`PersistStorage`](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/zustand-middleware#persiststorage) and selecting persisted fields with `partialize`.

```ts title="fish-store.ts"
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { urlStorage } from './hash-storage'

type FishData = {
  fishes: number
}

type FishStore = FishData & {
  addAFish: () => void
}

export const useFishStore = create<FishStore>()(
  persist(
    (set) => ({
      fishes: 0,
      addAFish: () => set((state) => ({ fishes: state.fishes + 1 })),
    }),
    {
      name: 'food-storage',
      storage: createJSONStorage<FishData>(() => urlStorage),
      partialize: (state) => ({ fishes: state.fishes }),
      version: 0,
    },
  ),
)
```

Each action calls `set`, which updates the store and writes the selected data to storage. The JSON value has the persistence envelope `{"state":{"fishes":1},"version":0}`, not just `{"fishes":1}`. `URLSearchParams` encodes that string in the URL.

## 3. Mount the React counter

Use separate selectors for the count and action. This entry point mounts the counter into your project's `root` element.

```tsx title="main.tsx"
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { useFishStore } from './fish-store'

function FishCounter() {
  const fishes = useFishStore((state) => state.fishes)
  const addAFish = useFishStore((state) => state.addAFish)

  return (
    <main>
      <p>Fish: {fishes}</p>
      <button onClick={addAFish}>Add a fish</button>
      <p>Copy the address after adding fish to share this count.</p>
    </main>
  )
}

const root = document.getElementById('root')!
createRoot(root).render(
  <StrictMode>
    <FishCounter />
  </StrictMode>,
)
```

With no stored value, the counter starts at zero. Click **Add a fish**: the count increases and the address gains a `food-storage` hash parameter. Copy the address and open it in a new tab. The synchronous adapter hydrates the store during initialization, restoring the count.

Try the authors' [live hash demo](https://stackblitz.com/edit/vitejs-vite-9vg24prg).

## 4. Use query parameters instead

For query persistence, add the following adapter and change the import in `fish-store.ts` from `./hash-storage` to `./query-storage`. Keep the store and React counter unchanged.

This variant reads the named query parameter first and falls back to `localStorage` when that parameter is absent. Every update writes to both places. It uses `history.replaceState` to update the address in place without a refresh, preserving the path, other query parameters, and the hash.

```ts title="query-storage.ts"
import type { StateStorage } from 'zustand/middleware'

export const urlStorage: StateStorage<void> = {
  getItem: (key) => {
    const url = new URL(window.location.href)
    return url.searchParams.get(key) ?? window.localStorage.getItem(key)
  },
  setItem: (key, value) => {
    const url = new URL(window.location.href)
    url.searchParams.set(key, value)
    window.history.replaceState(window.history.state, '', url.toString())
    window.localStorage.setItem(key, value)
  },
  removeItem: (key) => {
    const url = new URL(window.location.href)
    url.searchParams.delete(key)
    window.history.replaceState(window.history.state, '', url.toString())
    window.localStorage.removeItem(key)
  },
}
```

Click **Add a fish** to populate the query parameter, then copy the address. Opening that link restores its count even if the receiving browser has a different locally stored count.

The authors' example conditionally writes to the URL only when query parameters already exist. This variant always populates the URL, so you can share state starting from an address with no query string.

Try the authors' [live query demo](https://stackblitz.com/edit/vitejs-vite-hyc97ynf).

## Options that matter

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `name` | `string` | Required | Sets the storage key and therefore the URL parameter name. Use a unique name per store. |
| `storage` | `PersistStorage<FishData>` | `createJSONStorage(() => window.localStorage)` | Replaces browser local storage with your JSON-wrapped URL adapter. |
| `partialize` | `(state: FishStore) => FishData` in this example | `(state) => state` | Selects the fields to persist and share. |
| `version` | `number` | `0` | Adds a version to the persistence envelope. A differing stored numeric version needs a migration to be used. |

## Pitfalls

- Do not parse or stringify JSON again inside these adapters. `createJSONStorage` already does both. Return `null` for a missing key, not an empty string that fails JSON parsing.
- URL data is editable. `createJSONStorage` parses JSON but does not validate its shape. For production use, implement a validating `PersistStorage` before accepting untrusted shared state.
- These adapters persist updates; they do not register listeners for subsequent URL navigation. Open the copied link as a new page to hydrate it. For explicit hydration control, see [Control persistence hydration](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/control-persistence-hydration).
- Keep browser-only URL storage out of a shared server store. For server-rendered React applications, see [Next.js store setup](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/nextjs-store-setup).

## Related

- [Persist store data](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/persist-store-data) — storage configuration and asynchronous hydration.
- [Migrate persisted state](https://bench-zustand-61.atloria.app/p/bench-zustand-61-pSJCWrWW5Y/developer/migrate-persisted-state) — keep older shared links compatible when the persisted shape changes.
