Skip to content

Commit

Permalink
feat: Add loader feature (#818)
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 authored Dec 27, 2024
1 parent 81f8346 commit 4762b04
Show file tree
Hide file tree
Showing 24 changed files with 630 additions and 14 deletions.
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{:ts}` 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}`](/docs/batching) returns).

<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>
// 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>

The loader function will accept the following input types to parse search params from:
- A string containing a fully qualified URL: `https://example.com/?foo=bar`
- A string containing just search params: `?foo=bar` (like `location.search{:ts}`)
- A `URL{:ts}` object
- A `URLSearchParams{:ts}` object
- A `Request{:ts}` object
- A `Record<string, string | string[] | undefined>{:ts}` (eg: `{ foo: 'bar' }{:ts}`)
- 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
Binary file modified packages/docs/public/og/server-side.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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

0 comments on commit 4762b04

Please sign in to comment.