Next.js

Next.js Query String Params: searchParams + useRouter

If `router.query` examples keep breaking on you, the problem is usually that you're mixing App Router and Pages Router APIs. Here is the exact fix for each case.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
6 min read

The symptom is simple: you open /dashboard?search=invoice&page=2, copy an old snippet, and get undefined, stale values, or the wrong API entirely.

The root cause is that Next.js now has two routing models, and the correct query-string API depends on where you read the params:

  • App Router Server Component page: use the searchParams prop
  • App Router Client Component: use useSearchParams()
  • Shared client component across both routers: useSearchParams() still works

Here is the exact fix for each case.

The App Router server-side fix#

If you are inside app/.../page.tsx, use the page prop. In the current Next.js docs, searchParams is a promise in modern App Router pages.

tsx
// app/dashboard/page.tsx
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const { search = '', page = '1' } = await searchParams
 
  return (
    <main>
      <h1>Dashboard</h1>
      <p>Search: {search}</p>
      <p>Page: {page}</p>
    </main>
  )
}

Use this when the query string affects data fetching, pagination, filtering, or metadata for the page itself.

The App Router client-side fix#

If the component is interactive and already marked 'use client', use useSearchParams() from next/navigation.

tsx
'use client'
 
import { useSearchParams } from 'next/navigation'
 
export default function SearchSummary() {
  const searchParams = useSearchParams()
  const search = searchParams.get('search') ?? ''
  const page = searchParams.get('page') ?? '1'
 
  return (
    <p>
      Searching for <strong>{search || 'everything'}</strong> on page {page}
    </p>
  )
}

For a statically rendered route, the parent page must provide a Suspense boundary. The Next.js hook documentation explains that this is required for the production build, even when development appears to work:

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import SearchSummary from './search-summary'
 
export default function Page() {
  return (
    <Suspense fallback={<p>Loading search...</p>}>
      <SearchSummary />
    </Suspense>
  )
}

Save the client component above as app/dashboard/search-summary.tsx. The same parent boundary is needed when the shared SearchBar below is used on a static App Router page. If production reports the missing boundary, follow the useSearchParams Suspense fix.

Two further details matter:

  1. useSearchParams() is read-only.
  2. In the App Router docs, Next.js explicitly recommends the page searchParams prop if you are already in a Server Component page.

The shared-component pattern that survives both routers#

This is the cleanest answer if you are migrating gradually or sharing a search bar between pages/ and app/.

tsx
'use client'
 
import { useSearchParams } from 'next/navigation'
 
export function SearchBar() {
  const searchParams = useSearchParams()
 
  if (!searchParams) {
    return <input defaultValue="" placeholder="Search..." />
  }
 
  const search = searchParams.get('search') ?? ''
 
  return <input defaultValue={search} placeholder="Search..." />
}

That fallback is important in Pages Router prerendering. The official Pages Router docs now document useSearchParams from next/navigation as a shared option across both routers.

The mistake that keeps causing confusion#

Most search results still mix old Pages Router answers with new App Router code.

If you copied something like this into app/page.tsx:

tsx
// Wrong mental model for a modern App Router page
const query = router.query.search

you are using the wrong abstraction. In the App Router, the page itself should usually read searchParams, and client components should use useSearchParams.

Which API should you choose?#

Use the page prop when:

  • the param changes the server query
  • the param affects SEO or pagination
  • the page should render from the URL alone

Use useSearchParams() when:

  • the component is client-only
  • you need live access after client navigation
  • you are building a reusable search or filter widget

The production-safe rule#

If the URL controls what data the page loads, read it on the server first and pass stable values downward. That keeps your route easy to reason about and avoids a lot of hydration and loading-state noise.

If you are already untangling router differences, these are worth reading next:

References#

Reading query params in a Route Handler#

None of the fixes above apply inside app/api/.../route.ts. A Route Handler is not a page — it receives no searchParams prop, and it isn't a component, so useSearchParams doesn't apply either. The query string arrives as part of the incoming Request, and the documented way to read it is request.nextUrl.searchParams, which is a standard URLSearchParams instance:

ts
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const query = searchParams.get('q') ?? ''
  const page = searchParams.get('page') ?? '1'
 
  return NextResponse.json({ query, page })
}

request.nextUrl is a Next.js extension of the standard URL object attached to NextRequest, so every URLSearchParams method — .get(), .getAll(), .has() — works exactly as it does on the client. This is also the point where a lot of teams debate whether a query-driven endpoint belongs in a Route Handler at all versus a Server Action; Next.js Server Actions vs API Routes: When to Use Each covers that tradeoff directly — the short version is that a Route Handler is the right choice specifically when an external client (not just your own app's forms) needs to call the endpoint through a stable HTTP contract. Server Actions use a framework-specific POST protocol and are directly reachable over HTTP too: they must check authentication and authorisation inside the action, as the Next.js security guide requires. Hiding the calling button does not protect the action.

Reading query params in Middleware#

Middleware runs before a request reaches any page or Route Handler, and it has the exact same request.nextUrl.searchParams shape as a Route Handler — not the searchParams page prop, and not useSearchParams, since Middleware isn't part of the React render tree at all:

ts
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const ref = request.nextUrl.searchParams.get('ref')
 
  if (ref === 'legacy-campaign') {
    const url = request.nextUrl.clone()
    url.searchParams.delete('ref')
    url.searchParams.set('utm_source', 'legacy-campaign')
    return NextResponse.redirect(url)
  }
 
  return NextResponse.next()
}

This is the pattern behind rewriting or redirecting based on a tracking parameter, an A/B test bucket, or a feature flag stored in the query string, before the page itself ever renders. One thing worth double-checking here: Middleware only runs for requests that match its matcher config (or all requests, if none is set), so a query param read that silently never fires is often a matcher scoping issue rather than a searchParams bug — exactly the class of problem covered in Next.js Middleware Not Running on Vercel: 3 Causes.

Further reading: Next.js Redirect from / to another page, Fix: NextRouter was not mounted. Also see Fix: useRouter query undefined on first render. Also see Fix Invariant: attempted to hard navigate to the same URL. Also see Fix Error: NEXT_REDIRECT logged in a Route Handler catch.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.