Zustand middleware for URL query string sync.
npm install zustand-querystringimport { create } from 'zustand';
import { querystring } from 'zustand-querystring';
const useStore = create(
querystring(
set => ({
search: '',
page: 1,
setSearch: search => set({ search }),
setPage: page => set({ page }),
}),
{
select: () => ({ search: true, page: true }),
},
),
);
// URL: ?search=hello&page=2querystring(storeCreator, {
select: undefined, // which fields to sync
key: false, // false | 'state'
prefix: '', // prefix for URL params
format: marked, // serialization format
map: undefined, // bidirectional store ↔ URL mapping
syncNull: false, // sync null values
syncUndefined: false, // sync undefined values
url: undefined, // request URL for SSR
});Controls which state fields sync to URL. Receives pathname, returns object with true for fields to sync.
// All fields
select: () => ({ search: true, page: true, filters: true });
// Route-based
select: pathname => ({
search: true,
filters: pathname.startsWith('/products'),
adminSettings: pathname.startsWith('/admin'),
});
// Nested fields
select: () => ({
user: {
name: true,
settings: { theme: true },
},
});false(default): Each field becomes a separate URL param?search=hello&page=2&filters.sort=name'state'(or any string): All state in one param?state=search%3Dhello%2Cpage%3A2
Adds prefix to all params. Use when multiple stores share URL.
querystring(storeA, { prefix: 'a_', select: () => ({ search: true }) });
querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) });
// URL: ?a_search=hello&b_filter=activeBy default, null and undefined reset to initial state (removed from URL). Set to true to write them.
Bidirectional mapping between store state and URL state. Use when the URL should represent a different shape than the store — e.g., a store keyed by dynamic IDs where the URL should only reflect the active entry.
to receives the selected state and pathname, returns the URL shape. from receives the parsed URL state and pathname, returns store state to merge. Types flow automatically: from's parameter type is inferred from to's return type.
interface Store {
filtersByOperation: Record<string, { filters: string[] }>;
aggregationByOperation: Record<string, string>;
setFilters: (opId: string, filters: string[]) => void;
}
const useStore = create<Store>()(
querystring(
set => ({
filtersByOperation: {},
aggregationByOperation: {},
setFilters: (opId, filters) =>
set(s => ({
filtersByOperation: { ...s.filtersByOperation, [opId]: { filters } },
})),
}),
{
key: 'state',
select: pathname => ({
filtersByOperation: pathname.startsWith('/view/'),
aggregationByOperation: pathname.startsWith('/view/'),
}),
map: {
to: (state, pathname) => {
const id = pathname.split('/').pop()!;
return {
filters: state.filtersByOperation?.[id]?.filters,
aggregation: state.aggregationByOperation?.[id],
};
},
from: (urlState, pathname) => {
// urlState is typed as { filters: string[] | undefined, aggregation: string | undefined }
const id = pathname.split('/').pop()!;
return {
...(urlState.filters
? { filtersByOperation: { [id]: { filters: urlState.filters } } }
: {}),
...(urlState.aggregation
? { aggregationByOperation: { [id]: urlState.aggregation } }
: {}),
};
},
},
},
),
);
// On /view/DAM_v1 → URL: ?state=filters@price_>10~,aggregation=dailyFor SSR, pass the request URL:
querystring(store, { url: request.url, select: () => ({ search: true }) });- On page load: URL → State
- On state change: State → URL (via
replaceState)
Only values different from initial state are written to URL:
// Initial: { search: '', page: 1, sort: 'date' }
// Current: { search: 'hello', page: 1, sort: 'name' }
// URL: ?search=hello&sort=name
// (page omitted - matches initial)Type handling:
- Objects: recursively diffed
- Arrays, Dates: compared as whole values
- Functions: never synced
Three built-in formats:
| Format | Example Output |
|---|---|
marked |
count:5,tags@a,b~ |
plain |
count=5&tags=a&tags=b |
json |
count=5&tags=%5B%22a%22%5D |
import { marked } from 'zustand-querystring/format/marked';
import { plain } from 'zustand-querystring/format/plain';
import { json } from 'zustand-querystring/format/json';
querystring(store, { format: plain });Type markers: : primitive, = string, @ array, . object
Delimiters: , separator, ~ terminator, _ escape
import { createFormat } from 'zustand-querystring/format/marked';
const format = createFormat({
typeObject: '.',
typeArray: '@',
typeString: '=',
typePrimitive: ':',
separator: ',',
terminator: '~',
escapeChar: '_',
datePrefix: 'D',
});Dot notation for nesting, repeated keys for arrays.
import { createFormat } from 'zustand-querystring/format/plain';
const format = createFormat({
entrySeparator: ',', // between entries in namespaced mode
nestingSeparator: '.', // for nested keys
arraySeparator: 'repeat', // 'repeat' for ?tags=a&tags=b, or ',' for ?tags=a,b
escapeChar: '_',
nullString: 'null',
undefinedString: 'undefined',
infinityString: 'Infinity', // string representation of Infinity
negativeInfinityString: '-Infinity',
nanString: 'NaN',
});Note on
arraySeparator: ',': With comma-separated arrays and dynamic keys (e.g.,initialState: { filters: {} }), a single array value likeos=CentOSis indistinguishable from a scalar string. Use'repeat'for dynamic keys, or normalize withArray.isArray(val) ? val : [val].
URL-encoded JSON. No configuration.
Implement QueryStringFormat:
import type {
QueryStringFormat,
QueryStringParams,
ParseContext,
} from 'zustand-querystring';
const myFormat: QueryStringFormat = {
// For key: 'state' (namespaced mode)
stringify(state: object): string {
return encodeURIComponent(JSON.stringify(state));
},
parse(value: string, ctx?: ParseContext): object {
return JSON.parse(decodeURIComponent(value));
},
// For key: false (standalone mode)
stringifyStandalone(state: object): QueryStringParams {
const result: QueryStringParams = {};
for (const [key, value] of Object.entries(state)) {
result[key] = [encodeURIComponent(JSON.stringify(value))];
}
return result;
},
parseStandalone(params: QueryStringParams, ctx: ParseContext): object {
const result: Record<string, unknown> = {};
for (const [key, values] of Object.entries(params)) {
result[key] = JSON.parse(decodeURIComponent(values[0]));
}
return result;
},
};
querystring(store, { format: myFormat });Types:
QueryStringParams=Record<string, string[]>(values always arrays)ctx.initialStateavailable for type coercion
const useStore = create(
querystring(
set => ({
query: '',
page: 1,
setQuery: query => set({ query, page: 1 }), // reset page on new query
setPage: page => set({ page }),
}),
{ select: () => ({ query: true, page: true }) },
),
);const useFilters = create(
querystring(filtersStore, {
prefix: 'f_',
select: () => ({ category: true, price: true }),
}),
);
const usePagination = create(
querystring(paginationStore, {
prefix: 'p_',
select: () => ({ page: true, limit: true }),
}),
);
// URL: ?f_category=shoes&f_price=100&p_page=2&p_limit=20// app/page.tsx
export default async function Page({ searchParams }) {
// Store reads from URL on init
}// Middleware
import { querystring } from 'zustand-querystring';
// Formats
import { marked, createFormat } from 'zustand-querystring/format/marked';
import { plain, createFormat } from 'zustand-querystring/format/plain';
import { json } from 'zustand-querystring/format/json';
// Types
import type {
QueryStringOptions,
QueryStringMap,
QueryStringFormat,
QueryStringParams,
ParseContext,
} from 'zustand-querystring';