diff --git a/packages/docs/content/docs/server-side.mdx b/packages/docs/content/docs/server-side.mdx
index 6242b39a8..d5b33625d 100644
--- a/packages/docs/content/docs/server-side.mdx
+++ b/packages/docs/content/docs/server-side.mdx
@@ -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).
+
+
+
+```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
+}
+
+export default async function Page({ searchParams }: PageProps) {
+ const { latitude, longitude } = await loadSearchParams(searchParams)
+ return
+
+ // Pro tip: you don't *have* to await the result.
+ // Pass the Promise object to children components wrapped in
+ // 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)
+}
+```
+
+
+
+
+ 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).
+
+
+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{:ts}` (eg: `{ foo: 'bar' }{:ts}`)
+- A `Promise{:ts}` of any of the above, in which case it also returns a Promise.
+
+## Cache
+
This feature is available for Next.js only.
@@ -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.
-
- 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)
-
+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 {
diff --git a/packages/docs/public/og/server-side.jpg b/packages/docs/public/og/server-side.jpg
index 8d6815b3b..b0af315ca 100644
Binary files a/packages/docs/public/og/server-side.jpg and b/packages/docs/public/og/server-side.jpg differ
diff --git a/packages/e2e/next/cypress/e2e/shared/loader.cy.ts b/packages/e2e/next/cypress/e2e/shared/loader.cy.ts
new file mode 100644
index 000000000..344294b99
--- /dev/null
+++ b/packages/e2e/next/cypress/e2e/shared/loader.cy.ts
@@ -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' })
diff --git a/packages/e2e/next/src/app/api/app/loader/route.ts b/packages/e2e/next/src/app/api/app/loader/route.ts
new file mode 100644
index 000000000..e66ff98aa
--- /dev/null
+++ b/packages/e2e/next/src/app/api/app/loader/route.ts
@@ -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(
+ `
+
+
+ hydrated
+ ${test}
+ ${int}
+
+`,
+ {
+ headers: {
+ 'content-type': 'text/html'
+ }
+ }
+ )
+}
diff --git a/packages/e2e/next/src/app/app/loader/page.tsx b/packages/e2e/next/src/app/app/loader/page.tsx
new file mode 100644
index 000000000..f0b78ab58
--- /dev/null
+++ b/packages/e2e/next/src/app/app/loader/page.tsx
@@ -0,0 +1,11 @@
+import { LoaderRenderer, loadSearchParams } from 'e2e-shared/specs/loader'
+import type { SearchParams } from 'nuqs/server'
+
+type PageProps = {
+ searchParams: Promise
+}
+
+export default async function Page({ searchParams }: PageProps) {
+ const serverValues = await loadSearchParams(searchParams)
+ return
+}
diff --git a/packages/e2e/next/src/app/app/push/page.tsx b/packages/e2e/next/src/app/app/push/page.tsx
index 54e1e567e..1c4d818d4 100644
--- a/packages/e2e/next/src/app/app/push/page.tsx
+++ b/packages/e2e/next/src/app/app/push/page.tsx
@@ -1,4 +1,5 @@
import type { SearchParams } from 'nuqs/server'
+import { Suspense } from 'react'
import { Client } from './client'
import { searchParamsCache } from './searchParams'
@@ -13,7 +14,9 @@ export default async function Page({
Server side: {server}
-
+
+
+
>
)
}
diff --git a/packages/e2e/next/src/pages/api/pages/loader.ts b/packages/e2e/next/src/pages/api/pages/loader.ts
new file mode 100644
index 000000000..294aecae0
--- /dev/null
+++ b/packages/e2e/next/src/pages/api/pages/loader.ts
@@ -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(
+ `
+
+
+ hydrated
+ ${test}
+ ${int}
+
+`
+ )
+}
diff --git a/packages/e2e/next/src/pages/pages/loader.tsx b/packages/e2e/next/src/pages/pages/loader.tsx
new file mode 100644
index 000000000..8169c3bb9
--- /dev/null
+++ b/packages/e2e/next/src/pages/pages/loader.tsx
@@ -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
+}
+
+export async function getServerSideProps({ query }: GetServerSidePropsContext) {
+ return {
+ props: {
+ serverValues: loadSearchParams(query)
+ }
+ }
+}
diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/loader.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/loader.cy.ts
new file mode 100644
index 000000000..4b7b289c4
--- /dev/null
+++ b/packages/e2e/react-router/v6/cypress/e2e/shared/loader.cy.ts
@@ -0,0 +1,3 @@
+import { testLoader } from 'e2e-shared/specs/loader.cy'
+
+testLoader({ path: '/loader' })
diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx
index bae460f1e..8e1540e5b 100644
--- a/packages/e2e/react-router/v6/src/react-router.tsx
+++ b/packages/e2e/react-router/v6/src/react-router.tsx
@@ -37,6 +37,7 @@ const router = createBrowserRouter(
+
))
diff --git a/packages/e2e/react-router/v6/src/routes/loader.tsx b/packages/e2e/react-router/v6/src/routes/loader.tsx
new file mode 100644
index 000000000..235446f1a
--- /dev/null
+++ b/packages/e2e/react-router/v6/src/routes/loader.tsx
@@ -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>
+ return
+}
diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts
index df91927ab..67987f620 100644
--- a/packages/e2e/react-router/v7/app/routes.ts
+++ b/packages/e2e/react-router/v7/app/routes.ts
@@ -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
diff --git a/packages/e2e/react-router/v7/app/routes/loader.tsx b/packages/e2e/react-router/v7/app/routes/loader.tsx
new file mode 100644
index 000000000..95505a8a9
--- /dev/null
+++ b/packages/e2e/react-router/v7/app/routes/loader.tsx
@@ -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
+}
diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/loader.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/loader.cy.ts
new file mode 100644
index 000000000..4b7b289c4
--- /dev/null
+++ b/packages/e2e/react-router/v7/cypress/e2e/shared/loader.cy.ts
@@ -0,0 +1,3 @@
+import { testLoader } from 'e2e-shared/specs/loader.cy'
+
+testLoader({ path: '/loader' })
diff --git a/packages/e2e/remix/app/routes/loader.tsx b/packages/e2e/remix/app/routes/loader.tsx
new file mode 100644
index 000000000..a9e846208
--- /dev/null
+++ b/packages/e2e/remix/app/routes/loader.tsx
@@ -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()
+ return
+}
diff --git a/packages/e2e/remix/cypress/e2e/shared/loader.cy.ts b/packages/e2e/remix/cypress/e2e/shared/loader.cy.ts
new file mode 100644
index 000000000..4b7b289c4
--- /dev/null
+++ b/packages/e2e/remix/cypress/e2e/shared/loader.cy.ts
@@ -0,0 +1,3 @@
+import { testLoader } from 'e2e-shared/specs/loader.cy'
+
+testLoader({ path: '/loader' })
diff --git a/packages/e2e/shared/specs/loader.cy.ts b/packages/e2e/shared/specs/loader.cy.ts
new file mode 100644
index 000000000..cd9b3fef3
--- /dev/null
+++ b/packages/e2e/shared/specs/loader.cy.ts
@@ -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')
+ })
+})
diff --git a/packages/e2e/shared/specs/loader.tsx b/packages/e2e/shared/specs/loader.tsx
new file mode 100644
index 000000000..c0e6470e7
--- /dev/null
+++ b/packages/e2e/shared/specs/loader.tsx
@@ -0,0 +1,27 @@
+import {
+ createLoader,
+ type inferParserType,
+ parseAsInteger,
+ parseAsString
+} from 'nuqs/server'
+
+const searchParams = {
+ test: parseAsString,
+ int: parseAsInteger
+}
+
+export type SearchParams = inferParserType
+export const loadSearchParams = createLoader(searchParams)
+
+type LoaderRendererProps = {
+ serverValues: inferParserType
+}
+
+export function LoaderRenderer({ serverValues }: LoaderRendererProps) {
+ return (
+ <>
+ {serverValues.test}
+ {serverValues.int}
+ >
+ )
+}
diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json
index 47b85b2a5..d09fa0c42 100644
--- a/packages/nuqs/package.json
+++ b/packages/nuqs/package.json
@@ -186,7 +186,7 @@
{
"name": "Server",
"path": "dist/server.js",
- "limit": "2 kB",
+ "limit": "2.5 kB",
"ignore": [
"react",
"next"
diff --git a/packages/nuqs/src/cache.ts b/packages/nuqs/src/cache.ts
index 22c2c3c78..7cc2a1306 100644
--- a/packages/nuqs/src/cache.ts
+++ b/packages/nuqs/src/cache.ts
@@ -2,6 +2,7 @@
import { cache } from 'react'
import type { SearchParams, UrlKeys } from './defs'
import { error } from './errors'
+import { createLoader } from './loader'
import type { inferParserType, ParserMap } from './parsers'
const $input: unique symbol = Symbol('Input')
@@ -10,6 +11,7 @@ export function createSearchParamsCache(
parsers: Parsers,
{ urlKeys = {} }: { urlKeys?: UrlKeys } = {}
) {
+ const load = createLoader(parsers, { urlKeys })
type Keys = keyof Parsers
type ParsedSearchParams = {
readonly [K in Keys]: inferParserType
@@ -43,11 +45,7 @@ export function createSearchParamsCache(
// Different inputs in the same request - fail
throw new Error(error(501))
}
- for (const key in parsers) {
- const parser = parsers[key]!
- const urlKey = urlKeys[key] ?? key
- c.searchParams[key] = parser.parseServerSide(searchParams[urlKey])
- }
+ c.searchParams = load(searchParams)
c[$input] = searchParams
return Object.freeze(c.searchParams) as ParsedSearchParams
}
diff --git a/packages/nuqs/src/index.server.ts b/packages/nuqs/src/index.server.ts
index 3bfc48457..f94d7bbc4 100644
--- a/packages/nuqs/src/index.server.ts
+++ b/packages/nuqs/src/index.server.ts
@@ -1,4 +1,10 @@
export { createSearchParamsCache } from './cache'
export type { HistoryOptions, Nullable, Options, SearchParams } from './defs'
+export {
+ createLoader,
+ type LoaderFunction,
+ type LoaderInput,
+ type LoaderOptions
+} from './loader'
export * from './parsers'
export { createSerializer } from './serializer'
diff --git a/packages/nuqs/src/index.ts b/packages/nuqs/src/index.ts
index b8bc6f515..cf144973d 100644
--- a/packages/nuqs/src/index.ts
+++ b/packages/nuqs/src/index.ts
@@ -1,4 +1,10 @@
export type { HistoryOptions, Nullable, Options, SearchParams } from './defs'
+export {
+ createLoader,
+ type LoaderFunction,
+ type LoaderInput,
+ type LoaderOptions
+} from './loader'
export * from './parsers'
export { createSerializer } from './serializer'
export * from './useQueryState'
diff --git a/packages/nuqs/src/loader.test.ts b/packages/nuqs/src/loader.test.ts
new file mode 100644
index 000000000..b7e24392b
--- /dev/null
+++ b/packages/nuqs/src/loader.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from 'vitest'
+import { createLoader } from './loader'
+import { parseAsInteger } from './parsers'
+
+describe('loader', () => {
+ describe('sync', () => {
+ it('parses a URL object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(new URL('http://example.com/?a=1&b=2'))
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a Request object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(new Request('http://example.com/?a=1&b=2'))
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a URLSearchParams object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(new URLSearchParams('a=1&b=2'))
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a Record object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load({
+ a: '1',
+ b: '2'
+ })
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a URL string', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load('https://example.com/?a=1&b=2')
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a search params string', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load('?a=1&b=2')
+ expect(result).toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('supports urlKeys', () => {
+ const load = createLoader(
+ {
+ urlKey: parseAsInteger
+ },
+ {
+ urlKeys: {
+ urlKey: 'a'
+ }
+ }
+ )
+ const result = load('?a=1')
+ expect(result).toEqual({
+ urlKey: 1
+ })
+ })
+ })
+
+ describe('async', () => {
+ it('parses a URL object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(
+ Promise.resolve(new URL('http://example.com/?a=1&b=2'))
+ )
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a Request object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(
+ Promise.resolve(new Request('http://example.com/?a=1&b=2'))
+ )
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a URLSearchParams object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(Promise.resolve(new URLSearchParams('a=1&b=2')))
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a Record object', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(
+ Promise.resolve({
+ a: '1',
+ b: '2'
+ })
+ )
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a URL string', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(Promise.resolve('https://example.com/?a=1&b=2'))
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('parses a search params string', () => {
+ const load = createLoader({
+ a: parseAsInteger,
+ b: parseAsInteger
+ })
+ const result = load(Promise.resolve('?a=1&b=2'))
+ return expect(result).resolves.toEqual({
+ a: 1,
+ b: 2
+ })
+ })
+ it('supports urlKeys', () => {
+ const load = createLoader(
+ {
+ urlKey: parseAsInteger
+ },
+ {
+ urlKeys: {
+ urlKey: 'a'
+ }
+ }
+ )
+ const result = load(Promise.resolve('?a=1'))
+ return expect(result).resolves.toEqual({
+ urlKey: 1
+ })
+ })
+ })
+})
diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts
new file mode 100644
index 000000000..6deb436b5
--- /dev/null
+++ b/packages/nuqs/src/loader.ts
@@ -0,0 +1,124 @@
+import type { UrlKeys } from './defs'
+import type { inferParserType, ParserMap } from './parsers'
+
+export type LoaderInput =
+ | URL
+ | Request
+ | URLSearchParams
+ | Record
+ | string
+
+export type LoaderOptions = {
+ urlKeys?: UrlKeys
+}
+
+export type LoaderFunction = ReturnType<
+ typeof createLoader
+>
+
+export function createLoader(
+ parsers: Parsers,
+ { urlKeys = {} }: LoaderOptions = {}
+) {
+ type ParsedSearchParams = inferParserType
+
+ /**
+ * Load & parse search params from (almost) any input.
+ *
+ * While loaders are typically used in the context of a React Router / Remix
+ * loader function, it can also be used in Next.js API routes or
+ * getServerSideProps functions, or even with the app router `searchParams`
+ * page prop (sync or async), if you don't need the cache behaviours.
+ */
+ function loadSearchParams(
+ input: LoaderInput,
+ options?: LoaderOptions
+ ): ParsedSearchParams
+
+ /**
+ * Load & parse search params from (almost) any input.
+ *
+ * While loaders are typically used in the context of a React Router / Remix
+ * loader function, it can also be used in Next.js API routes or
+ * getServerSideProps functions, or even with the app router `searchParams`
+ * page prop (sync or async), if you don't need the cache behaviours.
+ *
+ * Note: this async overload makes it easier to use against the `searchParams`
+ * page prop in Next.js 15 app router:
+ *
+ * ```tsx
+ * export default async function Page({ searchParams }) {
+ * const parsedSearchParamsPromise = loadSearchParams(searchParams)
+ * return (
+ * // Pre-render & stream the shell immediately
+ *
+ *
+ * // Stream the Promise down
+ *
+ *
+ *
+ * )
+ * }
+ * ```
+ */
+ function loadSearchParams(
+ input: Promise,
+ options?: LoaderOptions
+ ): Promise
+
+ function loadSearchParams(input: LoaderInput | Promise) {
+ if (input instanceof Promise) {
+ return input.then(i => loadSearchParams(i))
+ }
+ const searchParams = extractSearchParams(input)
+ const result = {} as any
+ for (const [key, parser] of Object.entries(parsers)) {
+ const urlKey = urlKeys[key] ?? key
+ const value = searchParams.get(urlKey)
+ result[key] = parser.parseServerSide(value ?? undefined)
+ }
+ return result
+ }
+ return loadSearchParams
+}
+
+function extractSearchParams(input: LoaderInput): URLSearchParams {
+ try {
+ if (input instanceof Request) {
+ if (input.url) {
+ return new URL(input.url).searchParams
+ } else {
+ return new URLSearchParams()
+ }
+ }
+ if (input instanceof URL) {
+ return input.searchParams
+ }
+ if (input instanceof URLSearchParams) {
+ return input
+ }
+ if (typeof input === 'object') {
+ const entries = Object.entries(input)
+ const searchParams = new URLSearchParams()
+ for (const [key, value] of entries) {
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ searchParams.append(key, v)
+ }
+ } else if (value !== undefined) {
+ searchParams.set(key, value)
+ }
+ }
+ return searchParams
+ }
+ if (typeof input === 'string') {
+ if ('canParse' in URL && URL.canParse(input)) {
+ return new URL(input).searchParams
+ }
+ return new URLSearchParams(input)
+ }
+ } catch (e) {
+ return new URLSearchParams()
+ }
+ return new URLSearchParams()
+}