Store any user state in query parameters; imagine JSON in a browser URL, while keeping types and structure of data, e.g.numbers will be decoded as numbers not strings. With TS validation. Shared state and URL state sync without any hassle or boilerplate. Supports Next.js@14-16, react-router@6-7, and Remix@2.
418
stars
299
commits
JavaScript
primary language
Sep 4, 2026
updated
English · 简体中文 · 日本語 · 한국어 · Русский · Español · Português (BR) · Français · Tiếng Việt

URI size limitation, up to 12KB is safe
Add a
and follow me to support the project!
Will appreciate you feedback/opinion on discussions
Share if it useful for you. X.com LinkedIn FB VK
state-in-url?Store any user state in query parameters; imagine JSON in a browser URL. All of it with keeping types and structure of data, e.g. numbers will be decoded as numbers not strings, dates as dates, etc, objects and arrays supported. Dead simple, fast, and with static Typescript validation. Deep links, aka URL synchronization, made easy.
Contains useUrlState hook for Next.js, react-router, Remix and Astro, and helpers for anything else on JS.
Since modern browsers support huge URLs and users don't care about query strings (it is a select all and copy/past workflow).
Time to use query string for state management, as it was originally intended. This library does all mundane stuff for you.
This library is a good alternative for NUQS.
React.useStateNext.js, react-router, Remix and Astro, helpers to use it with other frameworks or pure JSSearching 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.
| What | state-in-url | nuqs |
|---|---|---|
| Setup | None — import the hook and go | Adapter component wraps the app |
| State shape | One typed object, like React.useState | Per-key values, a parser declared for each |
| Reuse across components | Wrap the hook once — every component shares the state, no props | Extract your own hook around the parser map |
| Nested objects and arrays | Built in — structure and types preserved | JSON parser plus your own runtime validator |
| Dates | Preserved automatically | Built-in parser, declared per key |
| Size, full import | ~2.9 KB gzipped | ~6.7 KB gzipped |
| Runtime dependencies | None | One |
| Routers | Next.js, React Router v6/v7, Remix, Astro, plain JS helpers | Next.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.
The full comparison — the same feature built in both, other alternatives (TanStack Router, use-query-params) and migration notes — lives at https://state-in-url.dev/vs/nuqs.
state-in-url?
# npm
npm install --save state-in-url
# yarn
yarn add state-in-url
# pnpm
pnpm add state-in-url
In tsconfig.json in compilerOptions set "moduleResolution": "Bundler", or"moduleResolution": "Node16", or "moduleResolution": "NodeNext".
Possibly need to set "module": "ES2022", or "module": "ESNext"
state-in-url ships skill files for @tanstack/intent, so AI agents (Claude Code, Cursor, Copilot, Codex, etc.) load the right patterns and avoid common mistakes when using the library. After installing state-in-url, run once in your project:
npx @tanstack/intent@latest install
This wires your installed agent to discover the skills from node_modules/state-in-url/skills/. List available skills with npx @tanstack/intent@latest list.
Main hook that takes initial state as parameter and returns state object, callback to update url, and callback to update only state.
All components that use the same state object are automatically synchronized.
// userState.ts
// Only parameters with value different from default will go to the url.
export const userState: UserState = { name: '', age: 0 }
// use `Type` not `Interface`!
type UserState = { name: string, age: number }
'use client'
import { useUrlState } from 'state-in-url/next';
import { userState } from './userState';
function MyComponent() {
// can pass `replace` arg, it's control will `setUrl` will use `rounter.push` or `router.replace`, default replace=true
// can pass `searchParams` from server components, pass `useHistory: false` if you need to fetch smt in the server component
const { urlState, setUrl, setState } = useUrlState(userState);
return (
<div>
// urlState.name will return default value from `userState` if url empty
<input value={urlState.name}
// same api as React.useState, e.g. setUrl(currVal => currVal + 1)
onChange={(ev) => setUrl({ name: ev.target.value }) }
/>
<input value={urlState.age}
onChange={(ev) => setUrl({ age: +ev.target.value }) }
/>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
<button onClick={() => setUrl((_, initial) => initial)}>
Reset
</button>
</div>
)
}
export default async function Home({ searchParams }: { searchParams: object }) {
return (
<Form searchParams={searchParams} />
)
}
// Form.tsx
'use client'
import React from 'react';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
const Form = ({ searchParams }: { searchParams: object }) => {
const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
}
layout component// add to appropriate `layout.tsc`
export const runtime = 'edge';
// middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.url?.includes('_next') ? null : request.url;
const sp = url?.split?.('?')?.[1] || '';
const response = NextResponse.next();
if (url !== null) {
response.headers.set('searchParams', sp);
}
return response;
}
// Target layout component
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const sp = headers().get('searchParams') || '';
return (
<div>
<Comp1 searchParams={decodeState(sp, stateShape)} />
{children}
</div>
);
}
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { urlState, setUrl, setState } = useUrlState<object>(someObj);
}
API is same as for Next.js version, except can pass options from NavigateOptions type.
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
import { useUrlState } from 'state-in-url/remix';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
API is same as for Next.js version, except can pass options from NavigateOptions type.
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
import { useUrlState } from 'state-in-url/react-router';
// for react-router v6
// import { useUrlState } from 'state-in-url/react-router6';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
For React islands. Astro has no client-side router by default, so the hook writes the URL with window.history and reads it back on back/forward and on any other pushState/replaceState, Astro's own <ClientRouter /> included. Islands on a page share the state, with nothing to wrap them in.
// src/state.ts
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
The page must be rendered on demand (output: 'server', or export const prerender = false on the page, with an adapter): a prerendered page has no request, so the island gets {} and reads the URL after hydration.
---
// src/pages/index.astro
import { Form } from '../components/Form';
import { Status } from '../components/Status';
// The server render matches the URL, so hydration has nothing to correct.
// A plain object: island props are serialized, URLSearchParams is not.
const searchParams = Object.fromEntries(Astro.url.searchParams);
---
<Form client:load searchParams={searchParams} />
<Status client:load searchParams={searchParams} />
// src/components/Form.tsx
import React from 'react';
import { useUrlState } from 'state-in-url/astro';
import { form } from '../state';
export function Form({ searchParams }: { searchParams?: Record<string, string> }) {
const { urlState, setUrl, setState } = useUrlState(form, { searchParams });
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{ id: '1', value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') } },
{ id: '2', value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') } },
{ id: '3', value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') } },
];
// Status.tsx, a second island, reads the same state
export function Status({ searchParams }: { searchParams?: Record<string, string> }) {
const { urlState } = useUrlState(form, { searchParams });
return <pre>{JSON.stringify(urlState, null, 2)}</pre>;
}
Preact islands work the same way: with @astrojs/preact and compat: true, react resolves to preact/compat in both the server and the client build, and the import above is unchanged.
Without islands, on a page with no client framework at all, the same state lives in the frontmatter through decodeState and encodeState:
---
import { decodeState, encodeState } from 'state-in-url/encodeState';
import { form } from '../state';
const state = decodeState(Astro.url.searchParams, form);
const withName = encodeState({ ...state, name: 'Alice' }, form, Astro.url.searchParams);
---
<pre>{JSON.stringify(state)}</pre>
<a href={`?${withName}`}>Alice</a>
'use client';
import React from 'react';
import { useUrlState } from 'state-in-url/next';
const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: {id: string; value: {text: string; time: Date } }[];
};
export const useFormState = ({ searchParams }: { searchParams?: object }) => {
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 };
};
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
'use client'
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
function TagsComponent() {
// `urlState` will infer from Form type!
const { urlState, setUrl } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
React.useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// will compare state by content not by reference and fire update only for new values
setUrl(urlState);
}, 500);
return () => {
clearTimeout(timer.current);
};
}, [urlState, setUrl]);
Syncing state onBlur will be more aligned with real world usage.
<input onBlur={() => updateUrl()} .../>
useUrlStateBase hook for others routersHooks to create your own useUrlState hooks with other routers, e.g. react-router or tanstack router.
useSharedState hook for React.jsHook to share state between any React components, tested with Next.js and Vite.
'use client'
import { useSharedState } from 'state-in-url';
export const someState = { name: '' };
function SettingsComponent() {
const { state, setState } = useSharedState(someState);
}
useLinkProps hook for React.jsHook to carry the state to a link pointing at a different route, e.g. a language switcher. setUrl always writes to the current path; this doesn't.
'use client'
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useLinkProps } from 'state-in-url/useLinkProps';
export const form = { name: '' };
function LanguagePicker() {
const linkProps = useLinkProps(form, useRouter().push);
return <Link {...linkProps('/de/pricing')}>Deutsch</Link>;
}
The markup keeps the plain href, so crawlers and hreflang see the canonical URL; the state is read on click.
useUrlEncode hook for React.jsencodeState and decodeState helpersencode and decode helpersCan create state hooks for slices of state, and reuse them across application. For example:
type UserState = {
name: string;
age: number;
other: { id: string, value: number }[]
};
const userState = {
name: '',
age: 0,
other: [],
};
export const useUserState = () => {
const { urlState, setUrl, reset } = useUrlState(userState);
// other logic
// reset query params when navigating to other page
React.useEffect(() => {
return reset
}, [])
return { userState: urlState, setUserState: setUrl };;
}
Function, BigInt or Symbol won't work, probably things like ArrayBuffer neither. Everything that can be serialized to JSON will work.next.js 14/15/16 with app router, no plans to support pages.See Contributing doc
Next.jsreact-routerremixsvelteastroThis project is licensed under the MIT license.
I'm Aleksandr Smyshliaev — sole author and maintainer of this library. Senior frontend engineer (React / Next.js / TypeScript, 8+ years), and available for full-time remote work right now.
This library is the short version of what I'm good at: a typed API over a messy browser primitive, zero dependencies, and stability across Next.js, Remix and React Router through several React majors.
JavaScript
78.1%
TypeScript
20.6%
CSS
1.1%
Store any user state in query parameters; imagine JSON in a browser URL, while keeping types and structure of data, e.g.numbers will be decoded as numbers not strings. With TS validation. Shared state and URL state sync without any hassle or boilerplate. Supports Next.js@14-16, react-router@6-7, and Remix@2.
418
stars
299
commits
JavaScript
primary language
Sep 4, 2026
updated
English · 简体中文 · 日本語 · 한국어 · Русский · Español · Português (BR) · Français · Tiếng Việt

URI size limitation, up to 12KB is safe
Add a
and follow me to support the project!
Will appreciate you feedback/opinion on discussions
Share if it useful for you. X.com LinkedIn FB VK
state-in-url?Store any user state in query parameters; imagine JSON in a browser URL. All of it with keeping types and structure of data, e.g. numbers will be decoded as numbers not strings, dates as dates, etc, objects and arrays supported. Dead simple, fast, and with static Typescript validation. Deep links, aka URL synchronization, made easy.
Contains useUrlState hook for Next.js, react-router, Remix and Astro, and helpers for anything else on JS.
Since modern browsers support huge URLs and users don't care about query strings (it is a select all and copy/past workflow).
Time to use query string for state management, as it was originally intended. This library does all mundane stuff for you.
This library is a good alternative for NUQS.
React.useStateNext.js, react-router, Remix and Astro, helpers to use it with other frameworks or pure JSSearching 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.
| What | state-in-url | nuqs |
|---|---|---|
| Setup | None — import the hook and go | Adapter component wraps the app |
| State shape | One typed object, like React.useState | Per-key values, a parser declared for each |
| Reuse across components | Wrap the hook once — every component shares the state, no props | Extract your own hook around the parser map |
| Nested objects and arrays | Built in — structure and types preserved | JSON parser plus your own runtime validator |
| Dates | Preserved automatically | Built-in parser, declared per key |
| Size, full import | ~2.9 KB gzipped | ~6.7 KB gzipped |
| Runtime dependencies | None | One |
| Routers | Next.js, React Router v6/v7, Remix, Astro, plain JS helpers | Next.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.
The full comparison — the same feature built in both, other alternatives (TanStack Router, use-query-params) and migration notes — lives at https://state-in-url.dev/vs/nuqs.
state-in-url?
# npm
npm install --save state-in-url
# yarn
yarn add state-in-url
# pnpm
pnpm add state-in-url
In tsconfig.json in compilerOptions set "moduleResolution": "Bundler", or"moduleResolution": "Node16", or "moduleResolution": "NodeNext".
Possibly need to set "module": "ES2022", or "module": "ESNext"
state-in-url ships skill files for @tanstack/intent, so AI agents (Claude Code, Cursor, Copilot, Codex, etc.) load the right patterns and avoid common mistakes when using the library. After installing state-in-url, run once in your project:
npx @tanstack/intent@latest install
This wires your installed agent to discover the skills from node_modules/state-in-url/skills/. List available skills with npx @tanstack/intent@latest list.
Main hook that takes initial state as parameter and returns state object, callback to update url, and callback to update only state.
All components that use the same state object are automatically synchronized.
// userState.ts
// Only parameters with value different from default will go to the url.
export const userState: UserState = { name: '', age: 0 }
// use `Type` not `Interface`!
type UserState = { name: string, age: number }
'use client'
import { useUrlState } from 'state-in-url/next';
import { userState } from './userState';
function MyComponent() {
// can pass `replace` arg, it's control will `setUrl` will use `rounter.push` or `router.replace`, default replace=true
// can pass `searchParams` from server components, pass `useHistory: false` if you need to fetch smt in the server component
const { urlState, setUrl, setState } = useUrlState(userState);
return (
<div>
// urlState.name will return default value from `userState` if url empty
<input value={urlState.name}
// same api as React.useState, e.g. setUrl(currVal => currVal + 1)
onChange={(ev) => setUrl({ name: ev.target.value }) }
/>
<input value={urlState.age}
onChange={(ev) => setUrl({ age: +ev.target.value }) }
/>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
<button onClick={() => setUrl((_, initial) => initial)}>
Reset
</button>
</div>
)
}
export default async function Home({ searchParams }: { searchParams: object }) {
return (
<Form searchParams={searchParams} />
)
}
// Form.tsx
'use client'
import React from 'react';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
const Form = ({ searchParams }: { searchParams: object }) => {
const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
}
layout component// add to appropriate `layout.tsc`
export const runtime = 'edge';
// middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.url?.includes('_next') ? null : request.url;
const sp = url?.split?.('?')?.[1] || '';
const response = NextResponse.next();
if (url !== null) {
response.headers.set('searchParams', sp);
}
return response;
}
// Target layout component
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const sp = headers().get('searchParams') || '';
return (
<div>
<Comp1 searchParams={decodeState(sp, stateShape)} />
{children}
</div>
);
}
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { urlState, setUrl, setState } = useUrlState<object>(someObj);
}
API is same as for Next.js version, except can pass options from NavigateOptions type.
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
import { useUrlState } from 'state-in-url/remix';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
API is same as for Next.js version, except can pass options from NavigateOptions type.
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
import { useUrlState } from 'state-in-url/react-router';
// for react-router v6
// import { useUrlState } from 'state-in-url/react-router6';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
For React islands. Astro has no client-side router by default, so the hook writes the URL with window.history and reads it back on back/forward and on any other pushState/replaceState, Astro's own <ClientRouter /> included. Islands on a page share the state, with nothing to wrap them in.
// src/state.ts
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
The page must be rendered on demand (output: 'server', or export const prerender = false on the page, with an adapter): a prerendered page has no request, so the island gets {} and reads the URL after hydration.
---
// src/pages/index.astro
import { Form } from '../components/Form';
import { Status } from '../components/Status';
// The server render matches the URL, so hydration has nothing to correct.
// A plain object: island props are serialized, URLSearchParams is not.
const searchParams = Object.fromEntries(Astro.url.searchParams);
---
<Form client:load searchParams={searchParams} />
<Status client:load searchParams={searchParams} />
// src/components/Form.tsx
import React from 'react';
import { useUrlState } from 'state-in-url/astro';
import { form } from '../state';
export function Form({ searchParams }: { searchParams?: Record<string, string> }) {
const { urlState, setUrl, setState } = useUrlState(form, { searchParams });
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// Can update state immediately but sync change to url as needed
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{ id: '1', value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') } },
{ id: '2', value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') } },
{ id: '3', value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') } },
];
// Status.tsx, a second island, reads the same state
export function Status({ searchParams }: { searchParams?: Record<string, string> }) {
const { urlState } = useUrlState(form, { searchParams });
return <pre>{JSON.stringify(urlState, null, 2)}</pre>;
}
Preact islands work the same way: with @astrojs/preact and compat: true, react resolves to preact/compat in both the server and the client build, and the import above is unchanged.
Without islands, on a page with no client framework at all, the same state lives in the frontmatter through decodeState and encodeState:
---
import { decodeState, encodeState } from 'state-in-url/encodeState';
import { form } from '../state';
const state = decodeState(Astro.url.searchParams, form);
const withName = encodeState({ ...state, name: 'Alice' }, form, Astro.url.searchParams);
---
<pre>{JSON.stringify(state)}</pre>
<a href={`?${withName}`}>Alice</a>
'use client';
import React from 'react';
import { useUrlState } from 'state-in-url/next';
const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: {id: string; value: {text: string; time: Date } }[];
};
export const useFormState = ({ searchParams }: { searchParams?: object }) => {
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 };
};
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
'use client'
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
function TagsComponent() {
// `urlState` will infer from Form type!
const { urlState, setUrl } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
React.useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// will compare state by content not by reference and fire update only for new values
setUrl(urlState);
}, 500);
return () => {
clearTimeout(timer.current);
};
}, [urlState, setUrl]);
Syncing state onBlur will be more aligned with real world usage.
<input onBlur={() => updateUrl()} .../>
useUrlStateBase hook for others routersHooks to create your own useUrlState hooks with other routers, e.g. react-router or tanstack router.
useSharedState hook for React.jsHook to share state between any React components, tested with Next.js and Vite.
'use client'
import { useSharedState } from 'state-in-url';
export const someState = { name: '' };
function SettingsComponent() {
const { state, setState } = useSharedState(someState);
}
useLinkProps hook for React.jsHook to carry the state to a link pointing at a different route, e.g. a language switcher. setUrl always writes to the current path; this doesn't.
'use client'
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useLinkProps } from 'state-in-url/useLinkProps';
export const form = { name: '' };
function LanguagePicker() {
const linkProps = useLinkProps(form, useRouter().push);
return <Link {...linkProps('/de/pricing')}>Deutsch</Link>;
}
The markup keeps the plain href, so crawlers and hreflang see the canonical URL; the state is read on click.
useUrlEncode hook for React.jsencodeState and decodeState helpersencode and decode helpersCan create state hooks for slices of state, and reuse them across application. For example:
type UserState = {
name: string;
age: number;
other: { id: string, value: number }[]
};
const userState = {
name: '',
age: 0,
other: [],
};
export const useUserState = () => {
const { urlState, setUrl, reset } = useUrlState(userState);
// other logic
// reset query params when navigating to other page
React.useEffect(() => {
return reset
}, [])
return { userState: urlState, setUserState: setUrl };;
}
Function, BigInt or Symbol won't work, probably things like ArrayBuffer neither. Everything that can be serialized to JSON will work.next.js 14/15/16 with app router, no plans to support pages.See Contributing doc
Next.jsreact-routerremixsvelteastroThis project is licensed under the MIT license.
I'm Aleksandr Smyshliaev — sole author and maintainer of this library. Senior frontend engineer (React / Next.js / TypeScript, 8+ years), and available for full-time remote work right now.
This library is the short version of what I'm good at: a typed API over a messy browser primitive, zero dependencies, and stability across Next.js, Remix and React Router through several React majors.
JavaScript
78.1%
TypeScript
20.6%
CSS
1.1%