Skip to content

Commit

Permalink
feat: Export the SearchParams type in both client & server bundles (#710
Browse files Browse the repository at this point in the history
)
  • Loading branch information
franky47 authored Oct 25, 2024
1 parent 949297d commit 5a926f7
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 25 deletions.
33 changes: 25 additions & 8 deletions packages/docs/content/docs/seo.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ If your page uses query strings for local-only state, you should add a
canonical URL to your page, to tell SEO crawlers to ignore the query string
and index the page without it.

In the app router, this is done via the metadata object:
In the Next.js app router, this is done via the metadata object:

```ts
import type { Metadata } from 'next'
Expand All @@ -22,22 +22,39 @@ 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:

```ts
// page.tsx
```ts title="/app/watch/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, type SearchParams } from 'nuqs/server'

type Props = {
searchParams: { [key: string]: string | string[] | undefined }
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
}
return query
}
serialize(videoId) {
return videoId
}
})

export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const videoId = parseAsString.parseServerSide(searchParams.v)
const videoId = parseAsYouTubeVideoId.parseServerSide((await searchParams).v)
if (!videoId) {
notFound()
}
return {
alternates: {
canonical: `/watch?v=${videoId}`
Expand Down
3 changes: 1 addition & 2 deletions packages/nuqs/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// @ts-ignore
import { cache } from 'react'
import type { SearchParams } from './defs'
import { error } from './errors'
import type { ParserBuilder, inferParserType } from './parsers'

export type SearchParams = Record<string, string | string[] | undefined>

const $input: unique symbol = Symbol('Input')

export function createSearchParamsCache<
Expand Down
1 change: 1 addition & 0 deletions packages/nuqs/src/defs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TransitionStartFunction } from 'react'

export type SearchParams = Record<string, string | string[] | undefined>
export type HistoryOptions = 'replace' | 'push'

export type Options = {
Expand Down
3 changes: 2 additions & 1 deletion packages/nuqs/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { createSearchParamsCache, type SearchParams } from './cache'
export { createSearchParamsCache } from './cache'
export type { HistoryOptions, Options, SearchParams } from './defs'
export * from './parsers'
export { createSerializer } from './serializer'
2 changes: 1 addition & 1 deletion packages/nuqs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { HistoryOptions, Options } from './defs'
export type { HistoryOptions, Options, SearchParams } from './defs'
export * from './parsers'
export { createSerializer } from './serializer'
export * from './useQueryState'
Expand Down
20 changes: 7 additions & 13 deletions packages/nuqs/src/serializer.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
import type { ParserBuilder } from './parsers'
import type { inferParserType, ParserBuilder } from './parsers'
import { renderQueryString } from './url-encoding'

type ExtractParserType<Parser> =
Parser extends ParserBuilder<any>
? ReturnType<Parser['parseServerSide']>
: never

type Base = string | URLSearchParams | URL
type Values<Parsers extends Record<string, ParserBuilder<any>>> = Partial<{
[K in keyof Parsers]?: ExtractParserType<Parsers[K]>
}>
type ParserWithOptionalDefault<T> = ParserBuilder<T> & { defaultValue?: T }

export function createSerializer<
Parsers extends Record<string, ParserWithOptionalDefault<any>>
>(parsers: Parsers) {
type Values = Partial<inferParserType<Parsers>>

/**
* Generate a query string for the given values.
*/
function serialize(values: Values<Parsers>): string
function serialize(values: Values): string
/**
* Append/amend the query string of the given base with the given values.
*
Expand All @@ -27,10 +21,10 @@ export function createSerializer<
* - another value is given for an existing key, in which case the
* search param will be updated
*/
function serialize(base: Base, values: Values<Parsers> | null): string
function serialize(base: Base, values: Values | null): string
function serialize(
baseOrValues: Base | Values<Parsers> | null,
values: Values<Parsers> | null = {}
baseOrValues: Base | Values | null,
values: Values | null = {}
) {
const [base, search] = isBase(baseOrValues)
? splitBase(baseOrValues)
Expand Down
50 changes: 50 additions & 0 deletions packages/nuqs/src/tests/serializer.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expectError, expectType } from 'tsd'
import { createSerializer, parseAsInteger, parseAsString } from '../../dist'

// prettier-ignore
{
const serialize = createSerializer({
foo: parseAsString,
bar: parseAsInteger
})
// It returns a string
expectType<string>(serialize({}))
expectType<string>(serialize({ foo: 'foo', bar: 42 }))
expectType<string>(serialize({ foo: null, bar: null }))
// With base
expectType<string>(serialize('/', {}))
expectType<string>(serialize('/', { foo: 'foo', bar: 42 }))
expectType<string>(serialize('/', { foo: null, bar: null }))
expectType<string>(serialize(new URLSearchParams(), {}))
expectType<string>(serialize(new URLSearchParams(), { foo: 'foo', bar: 42 }))
expectType<string>(serialize(new URLSearchParams(), { foo: null, bar: null }))
expectType<string>(serialize(new URL('https://example.com'), {}))
expectType<string>(serialize(new URL('https://example.com'), { foo: 'foo', bar: 42 }))
expectType<string>(serialize(new URL('https://example.com'), { foo: null, bar: null }))
// Clearing from base
expectType<string>(serialize('/', null))
expectType<string>(serialize(new URLSearchParams(), null))
expectType<string>(serialize(new URL('https://example.com'), null))
}

// It accepts partial inputs
{
const serialize = createSerializer({
foo: parseAsString,
bar: parseAsInteger
})

expectType<string>(serialize({ foo: 'foo' }))
expectType<string>(serialize({ bar: 42 }))
}

// It doesn't accept extra properties
{
const serialize = createSerializer({
foo: parseAsString,
bar: parseAsInteger
})
expectError(() => {
serialize({ nope: null })
})
}

0 comments on commit 5a926f7

Please sign in to comment.