Skip to content

feat: Add Standard Schema interface #965

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

Draft
wants to merge 9 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@ This monorepo contains:
- The source code for the `nuqs` NPM package, in [`packages/nuqs`](./packages/nuqs).
- A Next.js app under [`packages/docs`](./packages/docs) that serves the documentation and as a playground deployed at <https://nuqs.47ng.com>
- Test benches for [end-to-end tests](./packages/e2e) for each supported framework, driven by Cypress
- Examples of integration with other tools.

When running `next dev`, this will:

- Build the library and watch for changes using [`tsup`](https://tsup.egoist.dev/)
- Start the docs app, which will be available at <http://localhost:3000>.
- Start the end-to-end test benches:
- http://localhost:3001 - Next.js
- http://localhost:3002 - React SPA
- http://localhost:3003 - Remix
- http://localhost:3006 - React Router v6
- http://localhost:3007 - React Router v7
- http://localhost:3001 - [Next.js](./packages/e2e/next)
- http://localhost:3002 - [React SPA](./packages/e2e/react)
- http://localhost:3003 - [Remix](./packages/e2e/remix)
- http://localhost:3006 - [React Router v6](./packages/e2e/react-router/v6)
- http://localhost:3007 - [React Router v7](./packages/e2e/react-router/v7)
- Start the examples:
- http://localhost:4000 - [tRPC](./packages/examples/trpc)

## Testing

Expand Down
92 changes: 47 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,45 +285,6 @@ export default () => {
}
```

### Using parsers in Server Components

> Note: see the [Accessing searchParams in server components](#accessing-searchparams-in-server-components)
> section for a more user-friendly way to achieve type-safety.

If you wish to parse the searchParams in server components, you'll need to
import the parsers from `nuqs/server`, which doesn't include
the `"use client"` directive.

You can then use the `parseServerSide` method:

```tsx
import { parseAsInteger } from 'nuqs/server'

type PageProps = {
searchParams: {
counter?: string | string[]
}
}

const counterParser = parseAsInteger.withDefault(1)

export default function ServerPage({ searchParams }: PageProps) {
const counter = counterParser.parseServerSide(searchParams.counter)
console.log('Server side counter: %d', counter)
return (
...
)
}
```

See the [server-side parsing demo](<./packages/docs/src/app/playground/(demos)/pagination>)
for a live example showing how to reuse parser configurations between
client and server code.

> 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).

## Default value

When the query string is not present in the URL, the default behaviour is to
Expand Down Expand Up @@ -642,6 +603,10 @@ const { q, page } = loadSearchParams('?q=hello&page=2')

It accepts various types of inputs (strings, URL, URLSearchParams, Request, Promises, etc.). [Read more](https://nuqs.47ng.com/docs/server-side#loaders)

See the [server-side parsing demo](<./packages/docs/src/app/playground/(demos)/pagination>)
for a live example showing how to reuse parser configurations between
client and server code.

## Accessing searchParams in Server Components

If you wish to access the searchParams in a deeply nested Server Component
Expand Down Expand Up @@ -930,25 +895,62 @@ export const metadata: Metadata = {
If however the query string is defining what content the page is displaying
(eg: YouTube's watch URLs, like `https://www.youtube.com/watch?v=dQw4w9WgXcQ`),
your canonical URL should contain relevant query strings, and you can still
use `useQueryState` to read it:
use your parsers to read it, and to serialize the canonical URL:

```ts
// page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { useQueryState } from 'nuqs'
import { parseAsString } from 'nuqs/server'
import { notFound } from 'next/navigation'
import {
createParser,
parseAsString,
createLoader,
createSerializer,
type SearchParams,
type UrlKeys
} from 'nuqs/server'

const youTubeVideoIdRegex = /^[^"&?\/\s]{11}$/i
const youTubeSearchParams = {
videoId: createParser({
parse(query) {
if (!youTubeVideoIdRegex.test(query)) {
return null
}
return query
},
serialize(videoId) {
return videoId
}
})
}
const youTubeUrlKeys: UrlKeys<typeof youTubeSearchParams> = {
videoId: 'v'
}
const loadYouTubeSearchParams = createLoader(youTubeSearchParams, {
urlKeys: youTubeUrlKeys
})
const serializeYouTubeSearchParams = createSerializer(youTubeSearchParams, {
urlKeys: youTubeUrlKeys
})

// --

type Props = {
searchParams: { [key: string]: string | string[] | undefined }
searchParams: Promise<SearchParams>
}

export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const videoId = parseAsString.parseServerSide(searchParams.v)
const { videoId } = await loadYouTubeSearchParams(searchParams)
if (!videoId) {
notFound()
}
return {
alternates: {
canonical: `/watch?v=${videoId}`
canonical: serializeYouTubeSearchParams('/watch', { videoId })
// /watch?v=dQw4w9WgXcQ
}
}
}
Expand Down
63 changes: 44 additions & 19 deletions packages/docs/content/docs/seo.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,42 +22,67 @@ export const metadata: Metadata = {
If however the query string is defining what content the page is displaying
(eg: YouTube's watch URLs, like `https://www.youtube.com/watch?v=dQw4w9WgXcQ`),
your canonical URL should contain relevant query strings, and you can still
use your parsers to read it:
use your parsers to read it, and to serialize the canonical URL.

```ts title="/app/watch/page.tsx"
import type { Metadata, ResolvingMetadata } from 'next'
import { notFound } from "next/navigation";
import { createParser, parseAsString, type SearchParams } from 'nuqs/server'
import {
createParser,
parseAsString,
createLoader,
createSerializer,
type SearchParams,
type UrlKeys
} from 'nuqs/server'

type Props = {
searchParams: Promise<SearchParams>
}

// Normally you'd reuse custom parsers across your application,
// but for this example we'll define it here.
const youTubeVideoIdRegex = /^[^"&?\/\s]{11}$/i
const parseAsYouTubeVideoId = createParser({
parse(query) {
if (!youTubeVideoIdRegex.test(query)) {
return null
const youTubeSearchParams = {
videoId: createParser({
parse(query) {
if (!youTubeVideoIdRegex.test(query)) {
return null
}
return query
},
serialize(videoId) {
return videoId
}
return query
},
serialize(videoId) {
return videoId
})
}
const youTubeUrlKeys: UrlKeys<typeof youTubeSearchParams> = {
videoId: 'v'
}
const loadYouTubeSearchParams = createLoader(
youTubeSearchParams,
{
urlKeys: youTubeUrlKeys
}
})
)
const serializeYouTubeSearchParams = createSerializer(
youTubeSearchParams,
{
urlKeys: youTubeUrlKeys
}
)

// --

type Props = {
searchParams: Promise<SearchParams>
}

export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const videoId = parseAsYouTubeVideoId.parseServerSide((await searchParams).v)
const { videoId } = await loadYouTubeSearchParams(searchParams)
if (!videoId) {
notFound()
}
return {
alternates: {
canonical: `/watch?v=${videoId}`
canonical: serializeYouTubeSearchParams('/watch', { videoId })
// /watch?v=dQw4w9WgXcQ
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/examples/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "examples",
"version": "0.0.0",
"private": true
}
6 changes: 6 additions & 0 deletions packages/examples/trpc/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
11 changes: 11 additions & 0 deletions packages/examples/trpc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# tRPC + React Router

This example showcases how to use nuqs search params definitions as
validators for tRPC procedure inputs, so you can feed them URL state
and maintain end-to-end type safety.

See:

- [search-params.ts](./app/search-params.ts) for search params definitions
- [trpc.ts](./app/server/trpc.ts) for tRPC router and procedure definitions
- [inverted-coordinates.tsx](./app/components/inverted-coordinates.tsx) for usage
20 changes: 20 additions & 0 deletions packages/examples/trpc/app/components/inverted-coordinates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { useCoordinates } from '~/search-params'
import { useTRPC } from '~/utils/trpc'

export function InvertedCoordinates() {
const { latitude, longitude } = useCoordinates()
const tprc = useTRPC()
const { data } = useQuery(
tprc.invert.queryOptions({
latitude,
longitude
})
)
return (
<>
<p>Inverted coordinates: (last updated at: {data?.time ?? null})</p>
<pre>{JSON.stringify(data?.inverted, null, 2)}</pre>
</>
)
}
20 changes: 20 additions & 0 deletions packages/examples/trpc/app/components/random-coordinates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useCoordinates } from '~/search-params'

export function RandomCoordinates() {
const { latitude, longitude, setCoordinates } = useCoordinates()
return (
<div>
<button
onClick={() =>
setCoordinates({
latitude: Math.random() * 90,
longitude: Math.random() * 180
})
}
>
Set random coordinates
</button>
<pre>{JSON.stringify({ latitude, longitude }, null, 2)}</pre>
</div>
)
}
Loading
Loading