Announcing nuqs version 2
nuqs
?page=12

Pagination

Integer page index with server-side rendering

Rendering controls

Pagination controls (server-rendered)

Product list (server-rendered)

searchParams.tsSource on GitHub
import {
  createSearchParamsCache,
  createSerializer,
  parseAsInteger,
  parseAsStringLiteral
} from 'nuqs/server'

export const renderingOptions = ['server', 'client'] as const
export type RenderingOptions = (typeof renderingOptions)[number]

export const searchParams = {
  page: parseAsInteger.withDefault(1),
  renderOn: parseAsStringLiteral(renderingOptions).withDefault('server'),
  delay: parseAsInteger.withDefault(0)
}

export const searchParamsCache = createSearchParamsCache(searchParams)
export const serialize = createSerializer(searchParams)
import { Description } from '@/src/components/typography'
import { Separator } from '@/src/components/ui/separator'
import type { SearchParams } from 'nuqs/server'
import { Suspense } from 'react'
import { SourceOnGitHub } from '../_components/source-on-github'
import { getMetadata } from '../demos'
import { fetchProducts, pageCount } from './api'
import { ClientPaginationControls } from './pagination-controls.client'
import { ServerPaginationControls } from './pagination-controls.server'
import { ProductView } from './product'
import { RenderingControls } from './rendering-controls'
import { searchParamsCache } from './searchParams'

export const metadata = getMetadata('pagination')

type PageProps = {
  searchParams: Promise<SearchParams>
}

export default async function PaginationDemoPage({ searchParams }: PageProps) {
  // Allow nested RSCs to access the search params (in a type-safe way)
  await searchParamsCache.parse(searchParams)
  return (
    <>
      <h1>{metadata.title}</h1>
      <Description>{metadata.description}</Description>
      <h2>Rendering controls</h2>
      <Suspense>
        <RenderingControls />
      </Suspense>
      <Separator className="my-8" />
      <PaginationRenderer />
      <Suspense>
        <ProductSection />
      </Suspense>
      <SourceOnGitHub path="pagination/searchParams.ts" />
      <SourceOnGitHub path="pagination/page.tsx" />
      <SourceOnGitHub path="pagination/pagination-controls.server.tsx" />
      <SourceOnGitHub path="pagination/pagination-controls.client.tsx" />
    </>
  )
}

function PaginationRenderer() {
  // Showcasing the use of search params cache in nested RSCs
  const renderOn = searchParamsCache.get('renderOn')
  return (
    <>
      <h2>
        Pagination controls{' '}
        <small className="text-sm font-medium text-zinc-500">
          ({renderOn}-rendered)
        </small>
      </h2>
      {renderOn === 'server' && (
        <ServerPaginationControls numPages={pageCount} />
      )}
      <Suspense key="client">
        {renderOn === 'client' && (
          <ClientPaginationControls numPages={pageCount} />
        )}
      </Suspense>
    </>
  )
}

async function ProductSection() {
  const { page, delay } = searchParamsCache.all()
  const products = await fetchProducts(page, delay)
  return (
    <section>
      <h2>
        Product list{' '}
        <small className="text-sm font-medium text-zinc-500">
          (server-rendered)
        </small>
      </h2>
      {products.map(product => (
        <ProductView product={product} key={product.id} />
      ))}
    </section>
  )
}
pagination-controls.server.tsxSource on GitHub
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNextLink,
  PaginationPreviousLink
} from '@/src/components/ui/pagination'
import { cn } from '@/src/lib/utils'
import { searchParamsCache, serialize } from './searchParams'

type PaginationControlsProps = {
  numPages: number
}

// Use <Link> components to navigate between pages
export function ServerPaginationControls({
  numPages
}: PaginationControlsProps) {
  const { page, delay, renderOn } = searchParamsCache.all()
  function pageURL(page: number) {
    return serialize('/playground/pagination', {
      page,
      delay,
      renderOn
    })
  }
  return (
    <Pagination className="not-prose items-center gap-2">
      <PaginationContent>
        <PaginationItem>
          <PaginationPreviousLink
            href={pageURL(page - 1)}
            disabled={page === 1}
            scroll={false}
          />
        </PaginationItem>
        {Array.from({ length: numPages }, (_, i) => (
          <PaginationItem key={i}>
            <PaginationLink
              href={pageURL(i + 1)}
              isActive={page === i + 1}
              scroll={false}
            >
              {i + 1}
            </PaginationLink>
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationNextLink
            disabled={page === numPages}
            href={pageURL(page + 1)}
            scroll={false}
          />
        </PaginationItem>
      </PaginationContent>
      <div
        aria-label={'Loading status unavailable on the server'}
        className={cn('h-2 w-2 rounded-full bg-zinc-500')}
      />
    </Pagination>
  )
}
pagination-controls.client.tsxSource on GitHub
'use client'

import {
  Pagination,
  PaginationButton,
  PaginationContent,
  PaginationItem,
  PaginationNext,
  PaginationPrevious
} from '@/src/components/ui/pagination'
import { cn } from '@/src/lib/utils'
import { useQueryState } from 'nuqs'
import React from 'react'
import { searchParams } from './searchParams'

type PaginationControlsProps = {
  numPages: number
}

// Use client-side hooks to update the page number
// and observe the loading state
export function ClientPaginationControls({
  numPages
}: PaginationControlsProps) {
  const [isLoading, startTransition] = React.useTransition()
  const [page, setPage] = useQueryState(
    'page',
    searchParams.page.withOptions({
      startTransition,
      shallow: false // Send updates to the server
    })
  )
  return (
    <Pagination className="not-prose items-center gap-2">
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious
            disabled={page === 1}
            onClick={() => setPage(p => Math.max(1, p - 1))}
          />
        </PaginationItem>
        {Array.from({ length: numPages }, (_, i) => (
          <PaginationItem key={i}>
            <PaginationButton
              isActive={page === i + 1}
              onClick={() => setPage(i + 1)}
            >
              {i + 1}
            </PaginationButton>
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationNext
            disabled={page === numPages}
            onClick={() => setPage(p => Math.min(numPages, p + 1))}
          />
        </PaginationItem>
      </PaginationContent>
      <div
        aria-label={isLoading ? 'Loading' : 'Idle'}
        aria-live={isLoading ? 'polite' : undefined}
        className={cn(
          'h-2 w-2 rounded-full bg-green-500',
          isLoading && 'animate-pulse bg-amber-500'
        )}
      />
    </Pagination>
  )
}

On this page

No Headings