Syncing your Zustand state with the URL allows you to create shareable links that restore application state. This guide covers two approaches: URL hash and query parameters.
URL Hash Storage
Connect your store state to the URL hash using a custom storage implementation with the persist middleware.
Create hash storage
Implement a custom StateStorage that uses the URL hash: import { create } from 'zustand'
import { persist , StateStorage , createJSONStorage } from 'zustand/middleware'
const hashStorage : StateStorage = {
getItem : ( key ) : string => {
const searchParams = new URLSearchParams ( location . hash . slice ( 1 ))
const storedValue = searchParams . get ( key ) ?? ''
return JSON . parse ( storedValue )
},
setItem : ( key , newValue ) : void => {
const searchParams = new URLSearchParams ( location . hash . slice ( 1 ))
searchParams . set ( key , JSON . stringify ( newValue ))
location . hash = searchParams . toString ()
},
removeItem : ( key ) : void => {
const searchParams = new URLSearchParams ( location . hash . slice ( 1 ))
searchParams . delete ( key )
location . hash = searchParams . toString ()
},
}
Use with persist middleware
Apply the custom storage to your store: export const useBoundStore = create ()(
persist (
( set , get ) => ({
fishes: 0 ,
addAFish : () => set ({ fishes: get (). fishes + 1 }),
}),
{
name: 'food-storage' , // unique name
storage: createJSONStorage (() => hashStorage ),
},
),
)
With hash storage, your state is stored in the URL after the # symbol. For example: https://example.com/#food-storage={"state":{"fishes":5}}
URL Query Parameters
For more control, you can sync state with URL query parameters while also persisting to localStorage.
Create hybrid storage
This storage checks URL params first, then falls back to localStorage: import { create } from 'zustand'
import { persist , StateStorage , createJSONStorage } from 'zustand/middleware'
const getUrlSearch = () => {
return window . location . search . slice ( 1 )
}
const persistentStorage : StateStorage = {
getItem : ( key ) : string => {
// Check URL first
if ( getUrlSearch ()) {
const searchParams = new URLSearchParams ( getUrlSearch ())
const storedValue = searchParams . get ( key )
return JSON . parse ( storedValue as string )
} else {
// Otherwise, load from localStorage
return JSON . parse ( localStorage . getItem ( key ) as string )
}
},
setItem : ( key , newValue ) : void => {
// Update URL if query params exist
if ( getUrlSearch ()) {
const searchParams = new URLSearchParams ( getUrlSearch ())
searchParams . set ( key , JSON . stringify ( newValue ))
window . history . replaceState ( null , '' , `? ${ searchParams . toString () } ` )
}
// Always update localStorage
localStorage . setItem ( key , JSON . stringify ( newValue ))
},
removeItem : ( key ) : void => {
const searchParams = new URLSearchParams ( getUrlSearch ())
searchParams . delete ( key )
window . location . search = searchParams . toString ()
},
}
Define your store
Create a store with the hybrid storage: type LocalAndUrlStore = {
typesOfFish : string []
addTypeOfFish : ( fishType : string ) => void
numberOfBears : number
setNumberOfBears : ( newNumber : number ) => void
}
const storageOptions = {
name: 'fishAndBearsStore' ,
storage: createJSONStorage < LocalAndUrlStore >(() => persistentStorage ),
}
const useLocalAndUrlStore = create ()(
persist < LocalAndUrlStore >(
( set ) => ({
typesOfFish: [],
addTypeOfFish : ( fishType ) =>
set (( state ) => ({ typesOfFish: [ ... state . typesOfFish , fishType ] })),
numberOfBears: 0 ,
setNumberOfBears : ( numberOfBears ) => set (() => ({ numberOfBears })),
}),
storageOptions ,
),
)
Generate shareable URLs
Create helper functions to build shareable links: const buildURLSuffix = ( params , version = 0 ) => {
const searchParams = new URLSearchParams ()
const zustandStoreParams = {
state: {
typesOfFish: params . typesOfFish ,
numberOfBears: params . numberOfBears ,
},
version: version , // Zustand includes version in persisted state
}
// Key must match the store name from storageOptions
searchParams . set ( 'fishAndBearsStore' , JSON . stringify ( zustandStoreParams ))
return searchParams . toString ()
}
export const buildShareableUrl = ( params , version ) => {
return ` ${ window . location . origin } ? ${ buildURLSuffix ( params , version ) } `
}
Use in components
Generate and share URLs with embedded state: function ShareButton () {
const store = useLocalAndUrlStore ()
const handleShare = () => {
const url = buildShareableUrl ({
typesOfFish: store . typesOfFish ,
numberOfBears: store . numberOfBears ,
}, 0 )
navigator . clipboard . writeText ( url )
alert ( 'Shareable URL copied to clipboard!' )
}
return < button onClick ={ handleShare }> Share State </ button >
}
The generated URL would look like this (shown unencoded for readability):
https://localhost/search?fishAndBearsStore={"state":{"typesOfFish":["tilapia","salmon"],"numberOfBears":15},"version":0}
When someone visits this URL, the store automatically initializes with the embedded state.
Comparison
Hash Storage
Query Parameters
Pros:
Simple implementation
Doesn’t trigger page reloads
Changes don’t affect browser history
Cons:
Limited URL aesthetics
Only in URL, not persisted locally
Hash changes may interfere with routing
Pros:
Clean URL structure
Dual persistence (URL + localStorage)
Better for SEO and sharing
More control over when to sync
Cons:
More complex implementation
Requires careful history management
Larger state creates long URLs
Live Demos
Hash Storage Demo See URL hash storage in action
Query Parameters Demo Explore query parameter syncing
Be mindful of URL length limits (approximately 2000 characters in most browsers). For large state objects, consider storing only essential data in the URL or using compression.