Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add loader feature #818

Merged
merged 13 commits into from
Dec 27, 2024
129 changes: 122 additions & 7 deletions packages/docs/content/docs/server-side.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,126 @@ title: Server-Side usage
description: Type-safe search params on the server
---

## Loaders

To parse search params server-side, you can use a _loader_ function.

You create one using the `createLoader` function, by passing it your search params
descriptor object:

```tsx title="searchParams.tsx"
// [!code word:createLoader]
import { parseAsFloat, createLoader } from 'nuqs/server'

// Describe your search params, and reuse this in useQueryStates / createSerializer:
export const coordinatesSearchParams = {
latitude: parseAsFloat.withDefault(0)
longitude: parseAsFloat.withDefault(0)
}

export const loadSearchParams = createLoader(coordinatesSearchParams)
```

Here, `loadSearchParams{:ts}` is a function that parses search params and returns
state variables to be consumed server-side (the same state type that `useQueryStates{:ts}` returns).
franky47 marked this conversation as resolved.
Show resolved Hide resolved

<Tabs items={["Next.js (app router)", "Next.js (pages router)", "API routes", "Remix / React Router", "React / client-side"]}>

```tsx tab="Next.js (app router)" title="app/page.tsx"
// [!code word:loadSearchParams]
import { loadSearchParams } from './search-params'
import type { SearchParams } from 'nuqs/server'

type PageProps = {
searchParams: Promise<SearchParams>
}

export default async function Page({ searchParams }: PageProps) {
const { latitude, longitude } = await loadSearchParams(searchParams)
return <Map
lat={latitude}
lng={longitude}
/>

// Pro tip: you don't *have* to await the result.
// pass the Promise object to children components wrapped in Suspense
franky47 marked this conversation as resolved.
Show resolved Hide resolved
// to benefit from PPR / dynamicIO and serve a static outer shell
// immediately, while streaming in the dynamic parts that depend on
// the search params when they become available.
}
```

```ts tab="Next.js (pages router)" title="pages/index.tsx"
// [!code word:loadSearchParams]
import type { GetServerSidePropsContext } from 'next'

export async function getServerSideProps({ query }: GetServerSidePropsContext) {
const { latitude, longitude } = loadSearchParams(query)
// Do some server-side calculations with the coordinates
return {
props: { ... }
}
}
```

```tsx tab="Remix / React Router" title="app/routes/_index.tsx"
// [!code word:loadSearchParams]
export function loader({ request }: LoaderFunctionArgs) {
const { latitude, longitude } = loadSearchParams(request) // request.url works too
// Do some server-side calculations with the coordinates
return ...
}
```

```tsx tab="React / client-side"
// Note: you can also use this client-side (or anywhere, really),
// for a one-off parsing of non-reactive search params:

loadSearchParams('https://example.com?latitude=42&longitude=12')
loadSearchParams(location.search)
loadSearchParams(new URL(...))
loadSearchParams(new URLSearchParams(...))
```

```tsx tab="API routes"
// App router, eg: app/api/location/route.ts
export async function GET(request: Request) {
const { latitude, longitude } = loadSearchParams(request)
// ...
}

// Pages router, eg: pages/api/location.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
request: NextApiRequest,
response: NextApiResponse
) {
const { latitude, longitude } = loadSearchParams(request.query)
}
```

</Tabs>

<Callout type="warn" title="Note">
Loaders **don't validate** your data. If you expect positive integers
or JSON-encoded objects of a particular shape, you'll need to feed the result
of the loader to a schema validation library, like [Zod](https://zod.dev).

Built-in validation support is coming. [Read the RFC](https://github.com/47ng/nuqs/discussions/446).
Alternatively, you can build validation into [custom parsers](/docs/parsers/making-your-own).
</Callout>


franky47 marked this conversation as resolved.
Show resolved Hide resolved
- A string containing a fully qualified URL (eg: `https://example.com/?foo=bar`)
- A string containing just search params (like `location.search`, eg: `?foo=bar`)
- A `URL{:ts}` object
- A `URLSearchParams{:ts}` object
- A `Request{:ts}` object
- A `Record<string, string | string[] | undefined>{:ts}` (eg: `{ foo: 'bar' }{:ts}`)
franky47 marked this conversation as resolved.
Show resolved Hide resolved
- A `Promise{:ts}` of any of the above, in which case it also returns a Promise.

## Cache

<Callout>
This feature is available for Next.js only.
</Callout>
Expand All @@ -11,13 +131,8 @@ If you wish to access the searchParams in a deeply nested Server Component
(ie: not in the Page component), you can use `createSearchParamsCache{:ts}`
to do so in a type-safe manner.

<Callout type="warn" title="Note">
Parsers **don't validate** your data. If you expect positive integers
or JSON-encoded objects of a particular shape, you'll need to feed the result
of the parser to a schema validation library, like [Zod](https://zod.dev).

Built-in validation support is coming. [Read the RFC](https://github.com/47ng/nuqs/discussions/446)
</Callout>
Think of it as a loader combined with a way to propagate the parsed values down
the RSC tree, like Context would on the client.

```ts title="searchParams.ts"
import {
Expand Down
13 changes: 13 additions & 0 deletions packages/e2e/next/cypress/e2e/shared/loader.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { testLoader } from 'e2e-shared/specs/loader.cy'

// In page components:

testLoader({ path: '/app/loader', nextJsRouter: 'app' })

testLoader({ path: '/pages/loader', nextJsRouter: 'pages' })

// In API routes:

testLoader({ path: '/api/app/loader', nextJsRouter: 'app' })

testLoader({ path: '/api/pages/loader', nextJsRouter: 'pages' })
25 changes: 25 additions & 0 deletions packages/e2e/next/src/app/api/app/loader/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { loadSearchParams } from 'e2e-shared/specs/loader'
import { NextResponse } from 'next/server'

// Needed for Next.js 14.2.0 to 14.2.3
// (due to https://github.com/vercel/next.js/pull/66446)
export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
const { test, int } = loadSearchParams(request)
return new NextResponse(
`<!doctype html>
<html>
<body>
<div id="hydration-marker" style="display:none;" aria-hidden>hydrated</div>
<pre id="test">${test}</pre>
<pre id="int">${int}</pre>
</body>
</html>`,
{
headers: {
'content-type': 'text/html'
}
}
)
}
11 changes: 11 additions & 0 deletions packages/e2e/next/src/app/app/loader/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
import type { SearchParams } from 'nuqs/server'

type PageProps = {
searchParams: Promise<SearchParams>
}

export default async function Page({ searchParams }: PageProps) {
const serverValues = await loadSearchParams(searchParams)
return <LoaderRenderer serverValues={serverValues} />
}
5 changes: 4 additions & 1 deletion packages/e2e/next/src/app/app/push/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SearchParams } from 'nuqs/server'
import { Suspense } from 'react'
import { Client } from './client'
import { searchParamsCache } from './searchParams'

Expand All @@ -13,7 +14,9 @@ export default async function Page({
<p>
Server side: <span id="server-side">{server}</span>
</p>
<Client />
<Suspense>
<Client />
</Suspense>
</>
)
}
22 changes: 22 additions & 0 deletions packages/e2e/next/src/pages/api/pages/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { loadSearchParams } from 'e2e-shared/specs/loader'
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
request: NextApiRequest,
response: NextApiResponse
) {
const { test, int } = loadSearchParams(request.query)
response
.status(200)
.setHeader('content-type', 'text/html')
.send(
`<!doctype html>
<html>
<body>
<div id="hydration-marker" style="display:none;" aria-hidden>hydrated</div>
<pre id="test">${test}</pre>
<pre id="int">${int}</pre>
</body>
</html>`
)
}
22 changes: 22 additions & 0 deletions packages/e2e/next/src/pages/pages/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
type SearchParams,
LoaderRenderer,
loadSearchParams
} from 'e2e-shared/specs/loader'
import type { GetServerSidePropsContext } from 'next'

type PageProps = {
serverValues: SearchParams
}

export default function Page({ serverValues }: PageProps) {
return <LoaderRenderer serverValues={serverValues} />
}

export async function getServerSideProps({ query }: GetServerSidePropsContext) {
return {
props: {
serverValues: loadSearchParams(query)
}
}
}
3 changes: 3 additions & 0 deletions packages/e2e/react-router/v6/cypress/e2e/shared/loader.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { testLoader } from 'e2e-shared/specs/loader.cy'

testLoader({ path: '/loader' })
1 change: 1 addition & 0 deletions packages/e2e/react-router/v6/src/react-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const router = createBrowserRouter(
<Route path="routing/useQueryStates/other" lazy={load(import('./routes/routing.useQueryStates.other'))} />
<Route path='shallow/useQueryState' lazy={load(import('./routes/shallow.useQueryState'))} />
<Route path='shallow/useQueryStates' lazy={load(import('./routes/shallow.useQueryStates'))} />
<Route path='loader' lazy={load(import('./routes/loader'))} />
</Route>
))

Expand Down
11 changes: 11 additions & 0 deletions packages/e2e/react-router/v6/src/routes/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'

export function loader({ request }: LoaderFunctionArgs) {
return loadSearchParams(request)
}

export default function Page() {
const serverValues = useLoaderData() as Awaited<ReturnType<typeof loader>>
return <LoaderRenderer serverValues={serverValues} />
}
1 change: 1 addition & 0 deletions packages/e2e/react-router/v7/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export default [
route('/routing/useQueryStates/other', './routes/routing.useQueryStates.other.tsx'),
route('/shallow/useQueryState', './routes/shallow.useQueryState.tsx'),
route('/shallow/useQueryStates', './routes/shallow.useQueryStates.tsx'),
route('/loader', './routes/loader.tsx')
])
] satisfies RouteConfig
13 changes: 13 additions & 0 deletions packages/e2e/react-router/v7/app/routes/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
import type { LoaderFunctionArgs } from 'react-router'
import type { Route } from './+types/loader'

export function loader({ request }: LoaderFunctionArgs) {
return loadSearchParams(request)
}

export default function Page({
loaderData: serverValues
}: Route.ComponentProps) {
return <LoaderRenderer serverValues={serverValues} />
}
3 changes: 3 additions & 0 deletions packages/e2e/react-router/v7/cypress/e2e/shared/loader.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { testLoader } from 'e2e-shared/specs/loader.cy'

testLoader({ path: '/loader' })
12 changes: 12 additions & 0 deletions packages/e2e/remix/app/routes/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'

export function loader({ request }: LoaderFunctionArgs) {
return loadSearchParams(request)
}

export default function Page() {
const serverValues = useLoaderData<typeof loader>()
return <LoaderRenderer serverValues={serverValues} />
}
3 changes: 3 additions & 0 deletions packages/e2e/remix/cypress/e2e/shared/loader.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { testLoader } from 'e2e-shared/specs/loader.cy'

testLoader({ path: '/loader' })
10 changes: 10 additions & 0 deletions packages/e2e/shared/specs/loader.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createTest } from '../create-test'

export const testLoader = createTest('Loader', ({ path }) => {
it('loads state from the URL', () => {
cy.visit(path + '?test=pass&int=42')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#test').should('have.text', 'pass')
cy.get('#int').should('have.text', '42')
})
})
27 changes: 27 additions & 0 deletions packages/e2e/shared/specs/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
createLoader,
type inferParserType,
parseAsInteger,
parseAsString
} from 'nuqs/server'

const searchParams = {
test: parseAsString,
int: parseAsInteger
}

export type SearchParams = inferParserType<typeof searchParams>
export const loadSearchParams = createLoader(searchParams)

type LoaderRendererProps = {
serverValues: inferParserType<typeof searchParams>
}

export function LoaderRenderer({ serverValues }: LoaderRendererProps) {
return (
<>
<pre id="test">{serverValues.test}</pre>
<pre id="int">{serverValues.int}</pre>
</>
)
}
2 changes: 1 addition & 1 deletion packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
{
"name": "Server",
"path": "dist/server.js",
"limit": "2 kB",
"limit": "2.5 kB",
"ignore": [
"react",
"next"
Expand Down
Loading
Loading