Next.js searchParams: Async Access, Type-Safe Parsing, and Server Component Patterns (2026)

searchParams became a Promise in Next.js 15. Learn how to await it in Server Components, parse safely with Zod, use nuqs for URL state, and keep pages statically prerendered with PPR.

Next.js 15 searchParams Async Guide (2026)

Updated: August 8, 2026

In Next.js 15 and later, searchParams is a Promise passed to page and layout components. You must await it before reading properties like searchParams.q, and this applies to every Server Component that receives it. This shift, which shipped alongside async params, exists because Next.js now defers URL query parsing until render time so the rest of the page can prerender statically. In this guide I'll walk through the API changes, how to parse searchParams safely with Zod, when to reach for useSearchParams instead, and the caching gotchas that trip up teams migrating from the Pages Router. (I hit most of these personally while porting a mid-size marketplace app from Next 13 to 15.2, so a few of the warnings below come with scar tissue attached.)

  • Since Next.js 15, searchParams is a Promise<{ [key: string]: string | string[] | undefined }>, so you must await it in Server Components before use.
  • Reading searchParams automatically opts the route out of static rendering. Use Partial Prerendering (PPR) with a Suspense boundary to keep the shell static.
  • Validate with Zod's coerce helpers or z.enum so query strings become real numbers, dates, and enums instead of raw strings.
  • Use the async searchParams prop in Server Components; use useSearchParams() in Client Components (wrapped in Suspense).
  • nuqs is the community standard for two-way URL state. Think useState, but the value lives in the query string.
  • Middleware, generateMetadata, and generateStaticParams each see searchParams differently, and knowing which sees what saves hours of debugging.

What is searchParams in Next.js?

The searchParams prop is Next.js's way of handing a page or layout component the query string of the current URL. If the user visits /products?category=shoes&page=2, then searchParams resolves to { category: "shoes", page: "2" }. It's plumbed into the App Router's file conventions the same way params is. You don't wire anything up, you just accept the prop.

There are three shapes a value can take, and this is the part that surprises people migrating from the Pages Router. A single key produces a string. A repeated key like ?tag=js&tag=ts produces string[]. And a key present in the type but absent from the URL comes through as undefined. That's why the TypeScript signature is { [key: string]: string | string[] | undefined }: every access is inherently union-typed until you narrow it.

Only pages get searchParams. Layouts, templates, and nested Server Components do not. This is a hard rule, and it exists because layouts are cached separately from pages. Giving them access to query strings would break caching invariants. If you need a child Server Component to read the query, drill the value down as a prop from the page.

Why did searchParams become a Promise in Next.js 15?

Next.js 15 changed the type of searchParams from { [key: string]: string | string[] | undefined } to Promise<{ [key: string]: string | string[] | undefined }>. The same shift happened to params, which I covered in detail in my Next.js 15 params migration guide. The reason is the same. Next.js wants to keep as much of your route statically rendered as possible, and it can only do that if reading dynamic input is an explicit, awaited operation.

Under the old synchronous API, the moment you referenced searchParams.q, the entire page became dynamic, even the parts that had nothing to do with the query string. With Partial Prerendering (PPR), Next.js can now prerender the shell of your page and stream only the dynamic branch that reads searchParams. But that trick only works if the framework knows precisely where dynamic input enters, which is what the await boundary tells it.

The upgrade path is spelled out in the official Next.js page.js reference. So, there's a codemod: npx @next/codemod@canary next-async-request-api .. It handles the mechanical cases (add async, add await) but misses places where you destructure inline, pass searchParams as a prop, or read it outside a function boundary. Plan on hand-fixing 10 to 20 percent of call sites after the codemod runs. That was true in my last migration too; the codemod got me maybe 85 percent of the way.

How do you access searchParams in Server Components?

The minimum viable pattern in Next.js 15+ looks like this. Mark the page async, type searchParams as a Promise, and await it before reading anything.

// app/products/page.tsx
type SearchParams = Promise<{
  category?: string
  page?: string
  sort?: string
}>

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: SearchParams
}) {
  const { category, page = "1", sort = "newest" } = await searchParams

  const products = await getProducts({
    category,
    page: Number(page),
    sort,
  })

  return (
    <main>
      <ProductList products={products} />
    </main>
  )
}

A few things worth calling out. First, the destructured defaults (page = "1") run only when the key is undefined, not when it's an empty string. If someone hits /products?page=, page is "", not "1", and Number("") is 0. This is the classic footgun. Either coerce more carefully or use Zod (next section).

Second, if you need searchParams inside a child Server Component, pass it as a prop from the page:

// app/products/page.tsx
export default async function ProductsPage({ searchParams }: Props) {
  const params = await searchParams
  return <ProductFilters filters={params} />
}

// app/products/product-filters.tsx (Server Component)
export function ProductFilters({ filters }: { filters: { category?: string } }) {
  // no async, no await, the parent already resolved the Promise
  return <div>Filtering by: {filters.category ?? "all"}</div>
}

Third, if you only need to know whether a specific key is present (not its value), still use await. Next.js tracks the read, not the property access. There's no "peek" API.

Type-safe searchParams parsing with Zod

The default string | string[] | undefined union is honest but painful. In production, I always parse searchParams through a Zod schema. It turns query strings into real types (numbers, dates, enums), applies defaults, and gives you a single place to validate. Zod's coerce helpers were built exactly for this.

// lib/product-search-schema.ts
import { z } from "zod"

export const productSearchSchema = z.object({
  category: z.string().optional(),
  page: z.coerce.number().int().min(1).default(1),
  perPage: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["newest", "price-asc", "price-desc"]).default("newest"),
  tag: z.union([z.string(), z.array(z.string())]).optional(),
})

export type ProductSearch = z.infer<typeof productSearchSchema>
// app/products/page.tsx
import { productSearchSchema } from "@/lib/product-search-schema"

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const raw = await searchParams
  const result = productSearchSchema.safeParse(raw)

  if (!result.success) {
    // Invalid query, render an error or redirect to clean URL
    redirect("/products")
  }

  const { category, page, perPage, sort, tag } = result.data
  const products = await getProducts({ category, page, perPage, sort, tag })

  return <ProductList products={products} />
}

Notice the z.union([z.string(), z.array(z.string())]) for tag. That's the honest way to model "may be single or repeated." If you want to always end up with an array, wrap it: .transform((v) => (Array.isArray(v) ? v : v ? [v] : [])). Zod v4 added z.stringbool() and improved z.iso.datetime() which are both useful for query strings. Check the Zod docs for the current API.

The same pattern applies to form validation with React Hook Form and Zod. You end up with one schema owning both directions of the input funnel, which honestly is one of the best DX wins in the whole stack.

searchParams vs useSearchParams: which and when?

There are two ways to read the query string in App Router, and they solve different problems. Getting this wrong is the single most common source of confusion I see in code review.

AspectsearchParams propuseSearchParams() hook
RuntimeServerClient
Component typeServer Component (page only)Client Component ("use client")
Return typePromise<Record<string, string | string[]>>ReadonlyURLSearchParams
Reactive on navigationYes (re-renders page)Yes (re-renders component)
Available in layouts?NoYes (with Suspense)
Requires Suspense boundary?NoYes, or the whole route opts out of prerendering
Best forInitial data fetching, SEOInteractive filters, popovers, tabs

Rule of thumb: if the query drives what data you fetch, use the server prop. If the query drives which client-side UI is open (a modal, a tab, a filter panel), use the hook. If both are true (you fetch based on filters and let users tweak filters interactively), use both, in the same route, at different levels.

// app/products/filter-tabs.tsx
"use client"
import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Suspense } from "react"

function TabsInner() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const pathname = usePathname()
  const current = searchParams.get("category") ?? "all"

  function select(next: string) {
    const params = new URLSearchParams(searchParams.toString())
    if (next === "all") params.delete("category")
    else params.set("category", next)
    router.push(`${pathname}?${params.toString()}`)
  }

  return (
    <div role="tablist">
      {["all", "shoes", "shirts"].map((t) => (
        <button
          key={t}
          role="tab"
          aria-selected={current === t}
          onClick={() => select(t)}
        >
          {t}
        </button>
      ))}
    </div>
  )
}

export function FilterTabs() {
  // useSearchParams needs a Suspense boundary. Otherwise the whole route
  // gets marked dynamic and can't prerender.
  return (
    <Suspense fallback={<div>Loading filters...</div>}>
      <TabsInner />
    </Suspense>
  )
}

URL state management with nuqs

Writing the "read, mutate, push" dance from the last example gets old fast. nuqs is the community-standard library for treating query params like useState. It handles serialization, batching, throttling, and (critically) plays nicely with Server Components by exposing a createSearchParamsCache helper for the server side.

// app/products/filter-tabs.tsx
"use client"
import { useQueryState, parseAsString } from "nuqs"

export function FilterTabs() {
  const [category, setCategory] = useQueryState(
    "category",
    parseAsString.withDefault("all"),
  )

  return (
    <div role="tablist">
      {["all", "shoes", "shirts"].map((t) => (
        <button
          key={t}
          role="tab"
          aria-selected={category === t}
          onClick={() => setCategory(t === "all" ? null : t)}
        >
          {t}
        </button>
      ))}
    </div>
  )
}

On the server side, nuqs's cache gives you the same schema on the page:

// app/products/search-params.ts
import { createSearchParamsCache, parseAsString, parseAsInteger } from "nuqs/server"

export const productSearchCache = createSearchParamsCache({
  category: parseAsString.withDefault("all"),
  page: parseAsInteger.withDefault(1),
})

// app/products/page.tsx
import { productSearchCache } from "./search-params"

export default async function ProductsPage({ searchParams }: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const { category, page } = await productSearchCache.parse(searchParams)
  // now `category` is string, `page` is number, both with defaults applied
}

The point of the cache pattern isn't performance. It's using one schema definition on both sides of the client/server boundary. Change the parser once, and both the URL writer and the server reader stay in sync.

Filters, pagination, and sort, the real patterns

Ninety percent of what people do with searchParams is: filter a list, paginate it, sort it. Here's the pattern I use, all in Server Components, with pagination that survives back/forward navigation without extra fetches.

// app/products/page.tsx
import { productSearchSchema } from "@/lib/product-search-schema"
import { db } from "@/lib/db"
import { products } from "@/db/schema"
import { and, eq, desc, asc, count } from "drizzle-orm"

export default async function ProductsPage({ searchParams }: Props) {
  const { category, page, perPage, sort } = productSearchSchema.parse(await searchParams)

  const where = category ? eq(products.category, category) : undefined
  const orderBy =
    sort === "price-asc" ? asc(products.price) :
    sort === "price-desc" ? desc(products.price) :
    desc(products.createdAt)

  // Run count + rows in parallel, a common searchParams performance win
  const [rows, [{ total }]] = await Promise.all([
    db.select().from(products).where(where).orderBy(orderBy)
      .limit(perPage).offset((page - 1) * perPage),
    db.select({ total: count() }).from(products).where(where),
  ])

  return (
    <>
      <ProductList products={rows} />
      <Pagination page={page} perPage={perPage} total={total} />
    </>
  )
}

The pagination component is a Client Component that reads the current URL and links to ?page=N. It never posts, it never uses Server Actions, and its links are indexable. This is exactly what search engines want. If you're using Drizzle ORM for your data layer, the where/orderBy composition slots in naturally.

For search-as-you-type, put a debounced useQueryState on the input and let the Server Component re-render on each URL change. Because filters live in the URL, users can share the exact result set and hit Back to undo. Two features for free.

searchParams and caching: what actually changes

The moment a page reads searchParams, it becomes dynamic. That route won't be included in next build's static output. You can see this happen: run next build, look for the ƒ (dynamic) marker next to the route instead of ○ (static). With Cache Components and Partial Prerendering, you get finer control. The shell stays static and only the dynamic branch that reads searchParams streams.

// app/products/page.tsx, PPR-friendly structure
import { Suspense } from "react"

export const experimental_ppr = true

export default function ProductsPage({ searchParams }: Props) {
  // No await here, static shell renders immediately
  return (
    <>
      <h1>Products</h1>
      <CategoryLinks /> {/* fully static */}
      <Suspense fallback={<ProductListSkeleton />}>
        <DynamicProductList searchParamsPromise={searchParams} />
      </Suspense>
    </>
  )
}

async function DynamicProductList({ searchParamsPromise }: {
  searchParamsPromise: Promise<Record<string, string | string[] | undefined>>
}) {
  const params = await searchParamsPromise
  const products = await getProducts(params)
  return <ProductList products={products} />
}

Two things are worth internalizing. First, calling await searchParams in generateMetadata also makes metadata generation dynamic. This is usually fine but means Open Graph images can't be prerendered per URL. Second, Server Actions triggered by a filter form should call redirect() to a URL with the new query params, not revalidatePath(). You want the URL to reflect the new state so it's shareable and history-friendly.

Common searchParams errors and how to fix them

These are the top errors I see, in rough order of frequency. Honestly, I've shipped every single one of them at some point, so no judgment.

"searchParams should be awaited before using its properties"

You accessed a property before awaiting. The fix is literal: add async to the component and await the prop. Run the codemod (npx @next/codemod@canary next-async-request-api .) if you have many pages.

"useSearchParams() should be wrapped in a suspense boundary"

The hook forces the route out of static rendering unless the calling component is inside <Suspense>. Wrap the component (not the hook call) with <Suspense fallback={...}>. This shows up loudest during next build.

Query values are strings when I expected numbers

Query strings are always strings; ?page=2 gives you "2", not 2. Use Number(page), parseInt(page, 10), or z.coerce.number(). Watch out for empty strings coercing to 0 or NaN.

Filter changes don't update the URL

You're using router.replace(pathname) without appending the params, or window.history.pushState which the App Router doesn't observe. Use router.push(`${pathname}?${params}`) from next/navigation, or reach for useQueryState from nuqs and let it handle the router calls.

Stale data after Server Action

If a Server Action changes what your query filter should show, calling revalidatePath may not be enough. Sometimes the cache key includes the query string. This is a whole rabbit hole covered in the Next.js cache-not-revalidating fix guide.

Frequently Asked Questions

Do I need to await searchParams in every Next.js version?

Only in Next.js 15 and later. In Next.js 14 and earlier, searchParams was a plain object accessed synchronously. If you're upgrading, the next-async-request-api codemod handles most of the mechanical rewrite, but review destructured and passed-around usages by hand.

Why can't I use searchParams in layout.tsx?

Layouts don't receive searchParams because they cache independently of the query string. Allowing access would let stale layouts render for the wrong URL. If a layout needs the current query, use useSearchParams() in a Client Component inside the layout (wrapped in Suspense).

Does reading searchParams break Partial Prerendering?

No, but you have to structure the page correctly. Move the await searchParams into a component wrapped in <Suspense>. The parent page renders statically and only the dynamic branch streams. Without the boundary, the whole route becomes fully dynamic.

How do I get an array from searchParams for a repeated key?

Next.js already returns an array for repeated keys: ?tag=a&tag=b gives you ["a", "b"]. For a single occurrence you get a string. To normalize to always-array, wrap in [value].flat().filter(Boolean) or use Zod's .transform to unify the shape.

Is nuqs necessary or can I use raw URLSearchParams?

Raw URLSearchParams plus useRouter works fine for one or two filters. nuqs pays off once you have multiple query keys with parsers, defaults, and batched updates, and its server cache lets you share the schema between client hooks and Server Component reads.

Ben Howard
About the Author Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.