Skip to content

Commit 4762b04

Browse files
authored
feat: Add loader feature (#818)
1 parent 81f8346 commit 4762b04

File tree

24 files changed

+630
-14
lines changed

24 files changed

+630
-14
lines changed

packages/docs/content/docs/server-side.mdx

+122-7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,126 @@ title: Server-Side usage
33
description: Type-safe search params on the server
44
---
55

6+
## Loaders
7+
8+
To parse search params server-side, you can use a _loader_ function.
9+
10+
You create one using the `createLoader{:ts}` function, by passing it your search params
11+
descriptor object:
12+
13+
```tsx title="searchParams.tsx"
14+
// [!code word:createLoader]
15+
import { parseAsFloat, createLoader } from 'nuqs/server'
16+
17+
// Describe your search params, and reuse this in useQueryStates / createSerializer:
18+
export const coordinatesSearchParams = {
19+
latitude: parseAsFloat.withDefault(0)
20+
longitude: parseAsFloat.withDefault(0)
21+
}
22+
23+
export const loadSearchParams = createLoader(coordinatesSearchParams)
24+
```
25+
26+
Here, `loadSearchParams{:ts}` is a function that parses search params and returns
27+
state variables to be consumed server-side (the same state type that [`useQueryStates{:ts}`](/docs/batching) returns).
28+
29+
<Tabs items={["Next.js (app router)", "Next.js (pages router)", "API routes", "Remix / React Router", "React / client-side"]}>
30+
31+
```tsx tab="Next.js (app router)" title="app/page.tsx"
32+
// [!code word:loadSearchParams]
33+
import { loadSearchParams } from './search-params'
34+
import type { SearchParams } from 'nuqs/server'
35+
36+
type PageProps = {
37+
searchParams: Promise<SearchParams>
38+
}
39+
40+
export default async function Page({ searchParams }: PageProps) {
41+
const { latitude, longitude } = await loadSearchParams(searchParams)
42+
return <Map
43+
lat={latitude}
44+
lng={longitude}
45+
/>
46+
47+
// Pro tip: you don't *have* to await the result.
48+
// Pass the Promise object to children components wrapped in <Suspense>
49+
// to benefit from PPR / dynamicIO and serve a static outer shell
50+
// immediately, while streaming in the dynamic parts that depend on
51+
// the search params when they become available.
52+
}
53+
```
54+
55+
```ts tab="Next.js (pages router)" title="pages/index.tsx"
56+
// [!code word:loadSearchParams]
57+
import type { GetServerSidePropsContext } from 'next'
58+
59+
export async function getServerSideProps({ query }: GetServerSidePropsContext) {
60+
const { latitude, longitude } = loadSearchParams(query)
61+
// Do some server-side calculations with the coordinates
62+
return {
63+
props: { ... }
64+
}
65+
}
66+
```
67+
68+
```tsx tab="Remix / React Router" title="app/routes/_index.tsx"
69+
// [!code word:loadSearchParams]
70+
export function loader({ request }: LoaderFunctionArgs) {
71+
const { latitude, longitude } = loadSearchParams(request) // request.url works too
72+
// Do some server-side calculations with the coordinates
73+
return ...
74+
}
75+
```
76+
77+
```tsx tab="React / client-side"
78+
// Note: you can also use this client-side (or anywhere, really),
79+
// for a one-off parsing of non-reactive search params:
80+
81+
loadSearchParams('https://example.com?latitude=42&longitude=12')
82+
loadSearchParams(location.search)
83+
loadSearchParams(new URL(...))
84+
loadSearchParams(new URLSearchParams(...))
85+
```
86+
87+
```tsx tab="API routes"
88+
// App router, eg: app/api/location/route.ts
89+
export async function GET(request: Request) {
90+
const { latitude, longitude } = loadSearchParams(request)
91+
// ...
92+
}
93+
94+
// Pages router, eg: pages/api/location.ts
95+
import type { NextApiRequest, NextApiResponse } from 'next'
96+
export default function handler(
97+
request: NextApiRequest,
98+
response: NextApiResponse
99+
) {
100+
const { latitude, longitude } = loadSearchParams(request.query)
101+
}
102+
```
103+
104+
</Tabs>
105+
106+
<Callout type="warn" title="Note">
107+
Loaders **don't validate** your data. If you expect positive integers
108+
or JSON-encoded objects of a particular shape, you'll need to feed the result
109+
of the loader to a schema validation library, like [Zod](https://zod.dev).
110+
111+
Built-in validation support is coming. [Read the RFC](https://github.com/47ng/nuqs/discussions/446).
112+
Alternatively, you can build validation into [custom parsers](/docs/parsers/making-your-own).
113+
</Callout>
114+
115+
The loader function will accept the following input types to parse search params from:
116+
- A string containing a fully qualified URL: `https://example.com/?foo=bar`
117+
- A string containing just search params: `?foo=bar` (like `location.search{:ts}`)
118+
- A `URL{:ts}` object
119+
- A `URLSearchParams{:ts}` object
120+
- A `Request{:ts}` object
121+
- A `Record<string, string | string[] | undefined>{:ts}` (eg: `{ foo: 'bar' }{:ts}`)
122+
- A `Promise{:ts}` of any of the above, in which case it also returns a Promise.
123+
124+
## Cache
125+
6126
<Callout>
7127
This feature is available for Next.js only.
8128
</Callout>
@@ -11,13 +131,8 @@ If you wish to access the searchParams in a deeply nested Server Component
11131
(ie: not in the Page component), you can use `createSearchParamsCache{:ts}`
12132
to do so in a type-safe manner.
13133

14-
<Callout type="warn" title="Note">
15-
Parsers **don't validate** your data. If you expect positive integers
16-
or JSON-encoded objects of a particular shape, you'll need to feed the result
17-
of the parser to a schema validation library, like [Zod](https://zod.dev).
18-
19-
Built-in validation support is coming. [Read the RFC](https://github.com/47ng/nuqs/discussions/446)
20-
</Callout>
134+
Think of it as a loader combined with a way to propagate the parsed values down
135+
the RSC tree, like Context would on the client.
21136

22137
```ts title="searchParams.ts"
23138
import {
7.79 KB
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { testLoader } from 'e2e-shared/specs/loader.cy'
2+
3+
// In page components:
4+
5+
testLoader({ path: '/app/loader', nextJsRouter: 'app' })
6+
7+
testLoader({ path: '/pages/loader', nextJsRouter: 'pages' })
8+
9+
// In API routes:
10+
11+
testLoader({ path: '/api/app/loader', nextJsRouter: 'app' })
12+
13+
testLoader({ path: '/api/pages/loader', nextJsRouter: 'pages' })
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { loadSearchParams } from 'e2e-shared/specs/loader'
2+
import { NextResponse } from 'next/server'
3+
4+
// Needed for Next.js 14.2.0 to 14.2.3
5+
// (due to https://github.com/vercel/next.js/pull/66446)
6+
export const dynamic = 'force-dynamic'
7+
8+
export async function GET(request: Request) {
9+
const { test, int } = loadSearchParams(request)
10+
return new NextResponse(
11+
`<!doctype html>
12+
<html>
13+
<body>
14+
<div id="hydration-marker" style="display:none;" aria-hidden>hydrated</div>
15+
<pre id="test">${test}</pre>
16+
<pre id="int">${int}</pre>
17+
</body>
18+
</html>`,
19+
{
20+
headers: {
21+
'content-type': 'text/html'
22+
}
23+
}
24+
)
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
2+
import type { SearchParams } from 'nuqs/server'
3+
4+
type PageProps = {
5+
searchParams: Promise<SearchParams>
6+
}
7+
8+
export default async function Page({ searchParams }: PageProps) {
9+
const serverValues = await loadSearchParams(searchParams)
10+
return <LoaderRenderer serverValues={serverValues} />
11+
}

packages/e2e/next/src/app/app/push/page.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SearchParams } from 'nuqs/server'
2+
import { Suspense } from 'react'
23
import { Client } from './client'
34
import { searchParamsCache } from './searchParams'
45

@@ -13,7 +14,9 @@ export default async function Page({
1314
<p>
1415
Server side: <span id="server-side">{server}</span>
1516
</p>
16-
<Client />
17+
<Suspense>
18+
<Client />
19+
</Suspense>
1720
</>
1821
)
1922
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { loadSearchParams } from 'e2e-shared/specs/loader'
2+
import type { NextApiRequest, NextApiResponse } from 'next'
3+
4+
export default function handler(
5+
request: NextApiRequest,
6+
response: NextApiResponse
7+
) {
8+
const { test, int } = loadSearchParams(request.query)
9+
response
10+
.status(200)
11+
.setHeader('content-type', 'text/html')
12+
.send(
13+
`<!doctype html>
14+
<html>
15+
<body>
16+
<div id="hydration-marker" style="display:none;" aria-hidden>hydrated</div>
17+
<pre id="test">${test}</pre>
18+
<pre id="int">${int}</pre>
19+
</body>
20+
</html>`
21+
)
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {
2+
type SearchParams,
3+
LoaderRenderer,
4+
loadSearchParams
5+
} from 'e2e-shared/specs/loader'
6+
import type { GetServerSidePropsContext } from 'next'
7+
8+
type PageProps = {
9+
serverValues: SearchParams
10+
}
11+
12+
export default function Page({ serverValues }: PageProps) {
13+
return <LoaderRenderer serverValues={serverValues} />
14+
}
15+
16+
export async function getServerSideProps({ query }: GetServerSidePropsContext) {
17+
return {
18+
props: {
19+
serverValues: loadSearchParams(query)
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { testLoader } from 'e2e-shared/specs/loader.cy'
2+
3+
testLoader({ path: '/loader' })

packages/e2e/react-router/v6/src/react-router.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const router = createBrowserRouter(
3737
<Route path="routing/useQueryStates/other" lazy={load(import('./routes/routing.useQueryStates.other'))} />
3838
<Route path='shallow/useQueryState' lazy={load(import('./routes/shallow.useQueryState'))} />
3939
<Route path='shallow/useQueryStates' lazy={load(import('./routes/shallow.useQueryStates'))} />
40+
<Route path='loader' lazy={load(import('./routes/loader'))} />
4041
</Route>
4142
))
4243

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
2+
import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'
3+
4+
export function loader({ request }: LoaderFunctionArgs) {
5+
return loadSearchParams(request)
6+
}
7+
8+
export default function Page() {
9+
const serverValues = useLoaderData() as Awaited<ReturnType<typeof loader>>
10+
return <LoaderRenderer serverValues={serverValues} />
11+
}

packages/e2e/react-router/v7/app/routes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ export default [
1818
route('/routing/useQueryStates/other', './routes/routing.useQueryStates.other.tsx'),
1919
route('/shallow/useQueryState', './routes/shallow.useQueryState.tsx'),
2020
route('/shallow/useQueryStates', './routes/shallow.useQueryStates.tsx'),
21+
route('/loader', './routes/loader.tsx')
2122
])
2223
] satisfies RouteConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
2+
import type { LoaderFunctionArgs } from 'react-router'
3+
import type { Route } from './+types/loader'
4+
5+
export function loader({ request }: LoaderFunctionArgs) {
6+
return loadSearchParams(request)
7+
}
8+
9+
export default function Page({
10+
loaderData: serverValues
11+
}: Route.ComponentProps) {
12+
return <LoaderRenderer serverValues={serverValues} />
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { testLoader } from 'e2e-shared/specs/loader.cy'
2+
3+
testLoader({ path: '/loader' })
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { LoaderFunctionArgs } from '@remix-run/node'
2+
import { useLoaderData } from '@remix-run/react'
3+
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
4+
5+
export function loader({ request }: LoaderFunctionArgs) {
6+
return loadSearchParams(request)
7+
}
8+
9+
export default function Page() {
10+
const serverValues = useLoaderData<typeof loader>()
11+
return <LoaderRenderer serverValues={serverValues} />
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { testLoader } from 'e2e-shared/specs/loader.cy'
2+
3+
testLoader({ path: '/loader' })
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createTest } from '../create-test'
2+
3+
export const testLoader = createTest('Loader', ({ path }) => {
4+
it('loads state from the URL', () => {
5+
cy.visit(path + '?test=pass&int=42')
6+
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
7+
cy.get('#test').should('have.text', 'pass')
8+
cy.get('#int').should('have.text', '42')
9+
})
10+
})

packages/e2e/shared/specs/loader.tsx

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
createLoader,
3+
type inferParserType,
4+
parseAsInteger,
5+
parseAsString
6+
} from 'nuqs/server'
7+
8+
const searchParams = {
9+
test: parseAsString,
10+
int: parseAsInteger
11+
}
12+
13+
export type SearchParams = inferParserType<typeof searchParams>
14+
export const loadSearchParams = createLoader(searchParams)
15+
16+
type LoaderRendererProps = {
17+
serverValues: inferParserType<typeof searchParams>
18+
}
19+
20+
export function LoaderRenderer({ serverValues }: LoaderRendererProps) {
21+
return (
22+
<>
23+
<pre id="test">{serverValues.test}</pre>
24+
<pre id="int">{serverValues.int}</pre>
25+
</>
26+
)
27+
}

packages/nuqs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
{
187187
"name": "Server",
188188
"path": "dist/server.js",
189-
"limit": "2 kB",
189+
"limit": "2.5 kB",
190190
"ignore": [
191191
"react",
192192
"next"

0 commit comments

Comments
 (0)