Skip to main content
ASoc
Tutorial

Next.js Search Params: Three APIs, and the One This Codebase Uses

A Server Component prop, a client hook, and a Route Handler's own request object all read the same URL differently. This codebase's two real reads are both the third kind, for security reasons.

The ASoc Team9 min read

Next.js gives you three different ways to read a URL's search params depending on where the reading code runs, and they are not interchangeable: a Server Component's searchParams prop, the client-only useSearchParams() hook, and a Route Handler's own request object. This codebase's production code uses the third one twice, in two slightly different forms, for reasons that only show up once the params are guarding something.

The three APIs, at a glance

Where you areHow you read itSync or asyncClient or server
A page (Server Component)The searchParams propA Promise in Next.js 16 — await itServer
Any Client ComponentuseSearchParams() from next/navigationSync, read-only URLSearchParamsClient, needs "use client"
A Route Handler (route.ts)new URL(request.url).searchParams, or req.nextUrl.searchParamsSyncServer, but no React involved at all

The first two are the pair most tutorials cover — a page rendering different content based on the URL, or a client-side filter control reading and writing it. The third gets far less coverage, and it's the one this codebase actually reaches for, because both of its real search-param reads are security decisions, not rendering decisions.

The first two APIs, for contrast

Neither of these appears in this codebase's production code — its own searchParams reads are both Route Handlers, covered below — but they're worth seeing side by side with those, because the shape of each one is a direct consequence of where it runs.

A page (Server Component) receives searchParams as a prop rather than importing anything, and — since Next.js 15 — has to await it before reading a value:

// A Server Component page, generic shape
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ category?: string }>;
}) {
  const { category } = await searchParams;
  return <ProductGrid category={category} />;
}

A Client Component reads the same URL through a hook instead, synchronously, because it's running in the browser where there's no request/response cycle to await:

"use client";
import { useSearchParams } from "next/navigation";

export default function FilterControl() {
  const searchParams = useSearchParams();
  const category = searchParams.get("category");
  // ...
}

Same three letters, same underlying query string, two different shapes — one a Promise handed to a function that runs once per request, the other a live, synchronous object a component can re-read on every render as the URL changes under it.

Reading params in a Route Handler: two call sites, two spellings

src/app/auth/callback/route.ts is the PKCE code-exchange endpoint shared by email confirmation, Google OAuth, and password recovery — every flow that has to turn a ?code= query param into a signed-in session:

// src/app/auth/callback/route.ts
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = safeNext(searchParams.get("next"));

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      redirect(`${origin}${next}`);
    }
    console.error("auth/callback: exchangeCodeForSession error", error);
  }

  redirect(`${origin}/login?error=auth`);
}

That handler's request parameter is a plain web-standard Request, so it constructs a URL from request.url and reads .searchParams off that — the same API you'd use in any JavaScript runtime, nothing Next.js-specific about it.

src/app/api/download/route.ts — the entitlement-gated signed-URL issuer, GET /api/download?product=<slug>&framework=<fw> — reads the same way but from a different object:

// src/app/api/download/route.ts
export async function GET(req: NextRequest) {
  // ...
  const { searchParams } = req.nextUrl;
  const result = await resolveDownload(
    {
      productSlug: searchParams.get("product"),
      framework: searchParams.get("framework"),
      // ...
    },
    deps,
  );
  // ...
}

Here the parameter type is NextRequest, Next.js's extension of the standard Request, and it carries .nextUrl — an already-parsed URL Next.js builds for you, so there's no need to construct one from .url yourself. Both spellings return the same URLSearchParams object underneath; which one you write depends only on whether the handler typed its parameter as Request or NextRequest.

The rule a Route Handler read has to follow that a page's doesn't

Both call sites above read a param and then act on it server-side — one feeds a redirect, one selects a file to sign a URL for — which is a different risk profile than a page reading ?category= to decide what to render. Once a query param drives a redirect or a lookup rather than just a rendering decision, it needs the same scrutiny a form field would get. This codebase's own handling of the next param — the regex that rejects //host-style protocol-relative values and the /\host backslash variant WHATWG URL parsing treats the same way — is covered in full in React protected routes, so this post won't re-derive it; the short version is that a validator only checking "starts with a slash" would still ship an open redirect, and the fix rejects a second / or \ right after the first one too. The download route's own equivalent isn't a regex — resolveDownload() checks product/framework against the caller's actual entitlement rows rather than trusting the strings, which is the same discipline applied to a lookup instead of a redirect.

Why this isn't the same post as reading params on a page

Next.js App Router pages accept searchParams for a different job entirely — driving what gets rendered, like a shop page filtering products by a ?category= value. That's a Server Component reading a prop, not a Route Handler acting on user input, and this codebase's product-filtering post covers that side: the shareable-URL argument, the await searchParams promise shape, and when in-memory filtering beats a URL-driven one. The two posts read the same three-letter identifier — searchParams — off two structurally different objects, for two different reasons, which is exactly why conflating them is the mistake this post's troubleshooting table below calls out first.

A third rendering-side use is pagination — a ?page= value read the same way as a filter — and this blog's own pagination post makes the case for when that pattern is worth reaching for at all: /blog itself still renders every post on one page, well past the threshold where a real searchParams-driven pager would earn its complexity.

Mistakes and how they show up

SymptomCauseFix
useSearchParams is not a function / hook rules errorCalled in a Server ComponentAdd "use client", or read the searchParams prop instead if the component is a page
searchParams.get is not a function on a pageTreating the page prop as already-resolved in Next.js 16await searchParams first — it's a Promise now, not a plain object
A Route Handler's redirect target is exploitableThe raw query param string is passed straight into redirect()Validate against an allowlist pattern (same-origin, relative-only) before using it, as safeNext() does
req.nextUrl is undefinedThe handler typed its parameter as Request instead of NextRequestImport and use NextRequest from next/server, or fall back to new URL(request.url)
A client component re-renders on every keystroke while typing into a URL-synced filterEvery searchParams.set() call is pushed straight to the router with no debounceDebounce the router update, or update local state immediately and sync to the URL less often
searchParams.get("id") returns null unexpectedlyThe param appears more than once in the URL, or the key is cased differently than expectedURLSearchParams.get() returns only the first match — use .getAll() for repeated keys, and confirm the exact param name being sent

Frequently asked questions

Is searchParams a Promise in every version of Next.js? No — it became a Promise for both the page prop and route params as part of the App Router's move to async request APIs, starting in Next.js 15. Older code written against an earlier version will read it as a plain object; this codebase, on Next.js 16, awaits it.

Can I use useSearchParams() in a Server Component? No. It's a client-only hook — it needs the browser's live location, which a Server Component render has no access to. Read the searchParams prop on the page instead, or lift the value into a Client Component that calls the hook.

Do query params ever need validation the way next does here? Any param that drives a redirect, a database query, or a file path needs the same scrutiny — not just ones that look like URLs. product and framework in the download route above are validated against the caller's actual entitlements, not against a regex, because "is this a plausible-looking slug" and "is this user actually allowed to download it" are different questions.

What's the difference between request.url and request.nextUrl? request.url is the raw string every standard Request carries; calling new URL() on it is how you get a parseable object out of it, as auth/callback/route.ts does. request.nextUrl is Next.js doing that parsing for you ahead of time on a NextRequest, plus a few Next.js-specific fields (like nextUrl.pathname post-rewrite) a plain URL wouldn't have.

Does it matter which one I pick for a new Route Handler? Functionally, no — both end in the same URLSearchParams. The practical answer is to match whichever parameter type the handler already declares: a plain Request (no Next.js-specific fields needed) pairs naturally with new URL(request.url), and a NextRequest (needed for anything else Next.js adds, like cookies or geolocation in some deployments) already carries .nextUrl for free.

Templates in this post

ASoc Amplify markets a social-media-management SaaS with a results dashboard and a journal, ASoc Atelier is a designer's portfolio and studio site with a booking funnel, and ASoc Axiom markets a neural-networks AI consultancy with project-based pricing — all built on the same Next.js App Router this post's Route Handlers come from.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial11 min read

Shopping Cart in Next.js 16: Where Cart State Should Live

A cookie, a database row, or React Context? The three cart architectures compared, the add-to-cart Server Action, and the badge that quietly breaks static rendering.

Read more