Skip to content

Commit 7b6c6d9

Browse files
docs: astro block on landing
1 parent 555159c commit 7b6c6d9

29 files changed

Lines changed: 326 additions & 70 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ nothing but the `(tests)` group). It audits the production build: `build:demo`
146146
`dist/`) then `next start --port 3012`. Port 3012, not the demo's own 3002, so a
147147
server left over from `pnpm run dev` cannot be silently accepted in place of it.
148148

149-
**Only the three public pages**`/`, `/react-router`, `/remix`. The app also
149+
**Only the four public pages**`/`, `/react-router`, `/remix`, `/astro`. The app also
150150
serves `/useUrlState`, `/test-ssr` and the rest of the `(tests)` group, but those
151151
are e2e fixtures that happen to be deployed: they exist to be asserted against,
152152
not read, and several render in ways no real page would.
@@ -176,7 +176,7 @@ answers agents in four overlapping ways — they disagree about which to try:
176176
| --- | --- |
177177
| `/llms.txt` | `public/llms.txt` — the single source |
178178
| `/` under `Accept: text/markdown`, or a known agent user-agent — 307 to `/index.md` | `src/proxy.ts` |
179-
| `/index.md`, `/react-router.md`, `/remix.md` | `src/app/<route>.md/route.ts`, all three through `src/app/llmsTxtResponse.ts` |
179+
| `/index.md`, `/react-router.md`, `/remix.md`, `/astro.md` | `src/app/<route>.md/route.ts`, all four through `src/app/llmsTxtResponse.ts` |
180180
| `<link rel="alternate">` and a `Link:` response header | `src/app/seoStuff.ts` (`markdownAlternates`), `next.config.mjs` |
181181

182182
All three `.md` mirrors serve the same document, deliberately: this site is one

packages/example-nextjs16/next.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ const nextConfig = {
128128
// an `age` far past `s-maxage=600` — the client never sees a fresh response
129129
// and `<Link>` prefetch revalidates on a loop, measured at ~26 requests a
130130
// second from one idle tab. Its layout's `revalidate` caches it for a week.
131-
const demoPages = ['', '/react-router', '/remix'];
131+
const demoPages = ['', '/react-router', '/remix', '/astro'];
132132
const pages = [...demoPages, '/vs/nuqs'];
133133
const prefixes = ['', ...LOCALES.map((locale) => `/${locale.dir}`)];
134134

packages/example-nextjs16/public/llms.txt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# state-in-url
22

3-
> A React hook library for storing typed, JSON-serializable state in URL query parameters. ~2 KB, zero runtime deps. Supports the Next.js App Router (14/15/16), React Router 6/7, Remix 2, and plain React. MIT licensed.
3+
> A React hook library for storing typed, JSON-serializable state in URL query parameters. ~2 KB, zero runtime deps. Supports the Next.js App Router (14/15/16), React Router 6/7, Remix 2, Astro (React/Preact islands), and plain React. MIT licensed.
44

55
Last updated: 2026-08-31. Canonical: <https://state-in-url.dev/llms.txt>.
66

@@ -25,6 +25,7 @@ Available skills (under `node_modules/state-in-url/skills/`):
2525
| `input-handling` | core | Text inputs / sliders / fast-changing controls |
2626
| `nextjs-ssr` | framework | Next.js App Router: `searchParams` forwarding, Proxy for layouts |
2727
| `react-router-remix-setup` | framework | React Router v6/v7 or Remix v2 setup |
28+
| `astro-setup` | framework | Astro: React/Preact islands with the `searchParams` prop, or no client framework via `encodeState`/`decodeState` |
2829
| `form-library-integration` | composition | Pairing with `react-hook-form` (or formik) |
2930
| `shared-state-no-url` | core | `useSharedState` — cross-component state without URL sync |
3031

@@ -36,7 +37,7 @@ Full page: <https://state-in-url.dev/vs/nuqs> (Markdown: /vs/nuqs.md). Sizes: wh
3637

3738
| Library | Setup | Nested objects and dates | Size | Pick it when |
3839
|---|---|---|---|---|
39-
| state-in-url | None - import the hook | Preserved automatically, types included | ~2.9 KB gzip, zero deps | One typed object, zero setup, on Next.js / React Router / Remix |
40+
| state-in-url | None - import the hook | Preserved automatically, types included | ~2.9 KB gzip, zero deps | One typed object, zero setup, on Next.js / React Router / Remix / Astro |
4041
| nuqs | Adapter component, parser per key | JSON parser plus your own validator | ~6.7 KB gzip, one dep | Each value as its own readable query param |
4142
| TanStack Router | validateSearch on each route | JSON-first; dates need custom serialization | Built into the router | You are on TanStack Router |
4243
| use-query-params | Provider plus router adapter, param config per key | Via a JSON param type, loosely typed | ~4.4 KB gzip + serialize-query-params | A codebase already built on it |
@@ -57,6 +58,7 @@ Full page: <https://state-in-url.dev/vs/nuqs> (Markdown: /vs/nuqs.md). Sizes: wh
5758
| React Router | v7 | `state-in-url/react-router` |
5859
| React Router | v6 | `state-in-url/react-router6` |
5960
| Remix | v2 | `state-in-url/remix` |
61+
| Astro | React or Preact (compat) islands, on-demand rendered pages | `state-in-url/astro` |
6062
| Framework-agnostic | — | `state-in-url` (`useSharedState`), `state-in-url/encodeState` |
6163

6264
Pages Router is **not** supported.
@@ -228,6 +230,29 @@ import { useUrlState } from 'state-in-url/remix'; // Remix v2
228230
setUrl({ tab: 'b' }, { replace: false, preventScrollReset: true });
229231
```
230232

233+
## Astro
234+
235+
Same API inside a React (or Preact `compat: true`) island. Pass `Object.fromEntries(Astro.url.searchParams)` as the island's `searchParams` prop so the server render matches the URL — a plain object, `URLSearchParams` serializes to `{}`. The page must render on demand (`output: 'server'` or `export const prerender = false`, with an adapter). Islands sharing one default-state object share state; there is no client router, so `replace` defaults to `true`.
236+
237+
```astro
238+
---
239+
import { Filters } from '../components/Filters';
240+
const searchParams = Object.fromEntries(Astro.url.searchParams);
241+
---
242+
<Filters client:load searchParams={searchParams} />
243+
```
244+
245+
```typescript
246+
import { useUrlState } from 'state-in-url/astro';
247+
248+
export function Filters({ searchParams }: { searchParams?: Record<string, string> }) {
249+
const { urlState, setUrl } = useUrlState(FILTERS_STATE, { searchParams });
250+
...
251+
}
252+
```
253+
254+
No client framework at all: `decodeState(Astro.url.searchParams, FILTERS_STATE)` and `encodeState(next, FILTERS_STATE, Astro.url.searchParams)` from `state-in-url/encodeState` do the same job in the frontmatter, for links.
255+
231256
## react-hook-form integration
232257

233258
Share one defaults object, hydrate `useForm` from `urlState`, push form changes back via RHF `subscribe()` (not `watch()`):
@@ -278,7 +303,7 @@ Same module-scoped default-state-identity rule. Use only when URL sync is explic
278303

279304
- Full README (humans): https://github.com/asmyshlyaev177/state-in-url/blob/master/README.md
280305
- URL size limits: https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md
281-
- Working examples per framework: `packages/example-nextjs{14,15,16}`, `packages/example-react-router{6,7}`, `packages/example-remix2`
306+
- Working examples per framework: `packages/example-nextjs{14,15,16}`, `packages/example-react-router{6,7}`, `packages/example-remix2`, `packages/example-astro`
282307
- JSDoc comments are available in IDE for all exported functions
283308

284309
## Contact
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { File } from '../../components/File';
2+
import { CodeBlockState } from '../../CodeBlockState';
3+
import { tooltips } from '../../tooltips';
4+
import type { QuickStartCopy } from '../../i18n/copy/types';
5+
6+
export const CodeBlocksAstro = ({ copy }: { copy: QuickStartCopy }) => {
7+
return (
8+
<div className="flex flex-col gap-4">
9+
<h3 className="font-display text-ink text-3xl font-bold">{copy.title}</h3>
10+
<div className="codeTitle">{copy.stateStep}</div>
11+
<CodeBlockState />
12+
13+
<div className="codeTitle">{copy.hookStep}</div>
14+
<File
15+
name="useFormState"
16+
matchers={tooltips}
17+
content={`import { useUrlState } from 'state-in-url/astro';// [!code highlight:1]
18+
import { form } from './form';
19+
20+
// One hook per feature - the whole API for this state.
21+
// searchParams is the island prop: with it the server render matches the URL
22+
export const useFormState = (searchParams?: Record<string, string>) =>
23+
useUrlState(form, { searchParams });// [!code highlight:1]`}
24+
/>
25+
26+
<div className="codeTitle">{copy.componentsStep}</div>
27+
<File
28+
name="index.astro"
29+
matchers={tooltips}
30+
content={`---
31+
import { ComponentA } from '../components/ComponentA';
32+
import { ComponentB } from '../components/ComponentB';
33+
34+
// A plain object: island props are serialized, URLSearchParams is not
35+
const searchParams = Object.fromEntries(Astro.url.searchParams);// [!code highlight:1]
36+
---
37+
38+
<ComponentA client:load searchParams={searchParams} />
39+
<ComponentB client:load searchParams={searchParams} />`}
40+
/>
41+
<File
42+
name="ComponentA"
43+
matchers={tooltips}
44+
content={`import { useFormState } from './useFormState';// [!code highlight:1]
45+
46+
export const ComponentA = ({ searchParams }: { searchParams?: Record<string, string> }) => {
47+
// see docs for all possible params https://github.com/asmyshlyaev177/state-in-url/tree/master/packages/urlstate/astro/useUrlState
48+
const { urlState, setUrl, setState } = useFormState(searchParams);// [!code highlight:1]
49+
50+
return <>
51+
<input
52+
id="name"
53+
value={urlState.name} // [!code highlight:3]
54+
onChange={(ev) => setUrl({ name: ev.target.value })}
55+
/>
56+
// OR can update state immediately but sync change to url as needed
57+
<input
58+
value={urlState.name}
59+
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
60+
onBlur={() => setUrl()}
61+
/>
62+
<button onClick={() => setUrl((curr, initial) => initial)}>
63+
Reset
64+
</button>
65+
</>
66+
};`}
67+
/>
68+
<File
69+
name="ComponentB"
70+
matchers={tooltips}
71+
content={`import { useFormState } from './useFormState';// [!code highlight:1]
72+
73+
export const ComponentB = ({ searchParams }: { searchParams?: Record<string, string> }) => {
74+
// same state as ComponentA - a separate island, no props between them, no context
75+
const { urlState } = useFormState(searchParams);// [!code highlight:1]
76+
77+
// will be defaultValue from \`form\` if not in url, no need to check
78+
// [!code word:urlState]
79+
return <div>name: {urlState.name}</div>
80+
};`}
81+
/>
82+
<div className="codeTitle">{copy.advancedStep}</div>
83+
<File
84+
name="useFormState - extended"
85+
matchers={tooltips}
86+
content={`import React from 'react';
87+
import { useUrlState } from 'state-in-url/astro';
88+
import { form } from './form';
89+
90+
export const useFormState = (searchParams?: Record<string, string>) => {// [!code highlight:1]
91+
const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, { searchParams });
92+
93+
// first navigation will push new history entry
94+
// all following will just replace that entry
95+
// this way will have history with only 2 entries - ['/url', '/url?key=param']
96+
97+
const replace = React.useRef(false);
98+
const setUrl = React.useCallback((
99+
state: Parameters<typeof setUrlBase>[0],
100+
opts?: Parameters<typeof setUrlBase>[1]
101+
) => {
102+
setUrlBase(state, { replace: replace.current, ...opts });
103+
replace.current = true;
104+
}, [setUrlBase]);
105+
106+
return { urlState, setUrl, resetUrl: reset };
107+
};`}
108+
/>
109+
</div>
110+
);
111+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { copy } from '../../i18n/copy/en';
2+
import { DemoPage } from '../../pages/DemoPage';
3+
import { pageMetadata } from '../../seoStuff';
4+
import { CodeBlocksAstro } from './CodeBlocksAstro';
5+
6+
export const metadata = pageMetadata({
7+
path: '/astro',
8+
title: copy.meta.astro.title,
9+
description: copy.meta.astro.description,
10+
});
11+
12+
export default async function Home({ searchParams }: { searchParams: Promise<object> }) {
13+
return (
14+
<DemoPage searchParams={searchParams} copy={copy} codeBlocks={<CodeBlocksAstro copy={copy.quickStart} />} />
15+
);
16+
}

packages/example-nextjs16/src/app/(en)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const revalidate = 604800; // 7 days
1313

1414
/**
1515
* Root layout for the English pages, which are served unprefixed: `/`,
16-
* `/react-router`, `/remix`, and the Playwright fixture routes under
16+
* `/react-router`, `/remix`, `/astro`, and the Playwright fixture routes under
1717
* `(tests)`. `[locale]/layout.tsx` is the second root layout, for the eight
1818
* translations. See shell/RootDocument.tsx for why there are two.
1919
*/

packages/example-nextjs16/src/app/DemoPart.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function getUrls(pathname: string) {
7878
let routerKind: keyof typeof sourceUrls = 'next.js'
7979
if (pathname.includes('/react-router')) routerKind = 'react-router';
8080
else if (pathname.includes('/remix')) routerKind = 'remix.js';
81+
else if (pathname.includes('/astro')) routerKind = 'astro';
8182
else routerKind = 'next.js';
8283
const urls = sourceUrls[routerKind]
8384
return { routerKind, urls }
@@ -95,5 +96,9 @@ const sourceUrls = {
9596
'remix.js': {
9697
form: 'https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-remix2/app/routes/Form-for-test.tsx',
9798
status: 'https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-remix2/app/routes/Status-for-test.tsx'
99+
},
100+
astro: {
101+
form: 'https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-astro/src/components/Form-for-test.tsx',
102+
status: 'https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-astro/src/components/Status-for-test.tsx'
98103
}
99104
} as const;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { notFound } from 'next/navigation';
2+
3+
import { localeFromParam } from '../../i18n';
4+
import { copyFor } from '../../i18n/copy';
5+
import { DemoPage } from '../../pages/DemoPage';
6+
import { localeMetadata } from '../../seoStuff';
7+
import { CodeBlocksAstro } from '../../(en)/astro/CodeBlocksAstro';
8+
9+
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
10+
const { locale } = await params;
11+
return localeMetadata({ localeDir: locale, path: '/astro' });
12+
}
13+
14+
export default async function Home({
15+
params,
16+
searchParams,
17+
}: {
18+
params: Promise<{ locale: string }>;
19+
searchParams: Promise<object>;
20+
}) {
21+
const { locale: dir } = await params;
22+
const locale = localeFromParam(dir);
23+
if (!locale) notFound();
24+
25+
const copy = copyFor(locale.code);
26+
27+
return (
28+
<DemoPage searchParams={searchParams} copy={copy} vsHref={`/${dir}/vs/nuqs`} codeBlocks={<CodeBlocksAstro copy={copy.quickStart} />} />
29+
);
30+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { llmsTxtResponse } from '../llmsTxtResponse';
2+
3+
/**
4+
* `/astro.md` — the Markdown mirror of the Astro demo page.
5+
* See llmsTxtResponse.ts for why all three mirrors serve the same document.
6+
*/
7+
export const dynamic = 'force-static';
8+
9+
export const GET = llmsTxtResponse;

packages/example-nextjs16/src/app/components/AiSkills.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const SKILLS: ReadonlyArray<readonly [string, keyof AiSkillsTableCopy]> = [
1818
['input-handling', 'inputHandling'],
1919
['nextjs-ssr', 'nextjsSsr'],
2020
['react-router-remix-setup', 'reactRouterRemixSetup'],
21+
['astro-setup', 'astroSetup'],
2122
['form-library-integration', 'formLibraryIntegration'],
2223
['shared-state-no-url', 'sharedStateNoUrl'],
2324
];

0 commit comments

Comments
 (0)