Next.js API Query Params: Read Them, Then Stop Trusting Them
request.nextUrl.searchParams is one line; the next twenty decide whether the endpoint is safe. Walked through one entitlement-gated route handler.
In an App Router route handler, read query params off the request URL: const { searchParams } = request.nextUrl, then searchParams.get("product"). new URL(request.url).searchParams works identically. In the Pages Router it is req.query. Every value comes back string | null, and every value came from whoever called you.
That last clause is the whole article. Getting the value out is one line and every tutorial has it; the part that decides whether your endpoint is safe is what happens in the next twenty. This post walks the one entitlement-gated route handler in this storefront — GET /api/download?product=<slug>&framework=<fw>, which issues signed URLs for paid template zips — because it is a real endpoint where a wrong query param is a free download.
First, which searchParams do you mean?
Next.js 16 exposes query strings in four different places, and the wrong example is the most common reason a snippet does not compile.
| Where you are | How you read it | Shape |
|---|---|---|
app/api/*/route.ts (route handler) | request.nextUrl.searchParams | URLSearchParams |
app/*/page.tsx (Server Component) | the searchParams prop | Promise of an object — must be awaited |
| A Client Component | useSearchParams() | URLSearchParams, read-only |
pages/api/* (Pages Router) | req.query | object; repeated keys become string[] |
Only row one is this post. Row two is covered in get query params server-side, row three in Next.js search params, and the product-filtering pattern that uses both is in filtering in Next.js.
The row-two shape bites people coming from older examples. It is a promise now, which this repo's own sign-in page shows:
// src/app/login/page.tsx
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string; error?: string }>;
}) {
const { next: nextParam, error } = await searchParams;
const next = safeNext(nextParam);
// …
}
Destructuring it without await gives you undefined for every key and no error, which is a quiet half-hour.
Reading them in a route handler
request.nextUrl is a parsed NextURL, so there is nothing to construct:
import { type NextRequest } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const product = searchParams.get("product"); // string | null
const framework = searchParams.get("framework"); // string | null
}
Three facts about URLSearchParams worth knowing before you write the validation:
.get()returnsnullfor a missing key and""for?product=. Those are different inputs with the same meaning, so a truthiness check is right and a!== nullcheck is not..get()on a repeated key (?tag=a&tag=b) returns only the first value..getAll()returns both. Pick deliberately — an endpoint that silently drops the second value is a bug report later.- Decoding is already done.
?q=a%20barrives asa b; re-decoding it is how%2520turns into a mess.
Typed as string | null, which is what the production handler does, the compiler stops you from forgetting the empty case:
export interface DownloadParams {
productSlug: string | null;
framework: string | null;
ip: string | null;
userAgent: string | null;
}
Then stop trusting them
Here is the handler that reads the params in this codebase, src/app/api/download/route.ts:
const { searchParams } = req.nextUrl;
const result = await resolveDownload(
{
productSlug: searchParams.get("product"),
framework: searchParams.get("framework"),
ip: clientIp(req),
userAgent: req.headers.get("user-agent"),
},
deps,
);
Note what it does not do: no database call, no storage call, no decision. It reads four strings and hands them to resolveDownload in src/lib/download.ts, a function with no Request, no Response and no Supabase client in its signature. That split is deliberate — every branch of the deny chain is then unit-testable with in-memory fakes, and there is exactly one place to read when you want to know what the endpoint trusts.
The validation in that function has two halves, and they are different kinds of check.
Shape, before anything else is touched:
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const VALID_FRAMEWORKS = new Set<string>(Object.keys(FRAMEWORK_LABELS));
function isValidSlug(value: string): boolean {
return value.length > 0 && value.length <= 64 && SLUG_RE.test(value);
}
A length ceiling and an anchored pattern, plus an allow-list for framework built from the keys of the catalog's label map — so the set of accepted values cannot drift from the set of real ones. An allow-list is the only validation here that is cheap to keep correct; a deny-list of bad characters needs updating every time you learn a new one.
Then identity, which never comes from the URL:
const session = await deps.getSession();
if (!session) return { status: 401, message: "Sign in to download." };
if (!session.emailVerified) {
return { status: 403, message: "Verify your email before downloading." };
}
const { productSlug, framework } = params;
if (!productSlug || !framework || !isValidSlug(productSlug)) {
return { status: 400, message: BAD_REQUEST };
}
if (framework !== "backend" && !VALID_FRAMEWORKS.has(framework)) {
return { status: 400, message: BAD_REQUEST };
}
The order is the point. Session first, shape second, authorization third. No ?user= parameter exists at any point — the caller's identity comes from the cookie, and the slots they own are fetched with an explicit user_id filter. A query param says what is being asked for. It never says who is asking.
The query param we read and then deliberately discard
This is the case that does not appear in tutorials. One download target, the backend starter, is not a catalog product, so it has no slug to look up. The request still carries ?product=something&framework=backend, and product is now meaningless — but it would still be written into the audit row.
/**
* Canonical entitlement-target/audit-log identity for the backend zip. The
* `product` query param is never used to build this — `framework=backend`
* always resolves to this literal, so a caller can't skew the
* `download_events` record by passing an arbitrary `product` value alongside
* `framework=backend`.
*/
export const BACKEND_PRODUCT_SLUG = "backend";
A caller could send ?product=asoc-admin&framework=backend and, with the obvious implementation, get a legitimate backend download logged against a product they never touched. Nothing leaks and no gate opens, but the audit trail — the thing that makes a specific account answerable for a redistribution complaint — quietly becomes fiction.
So the branch substitutes the constant and drops the parameter:
if (framework === "backend") {
version = BACKEND_VERSION;
effectiveProductSlug = BACKEND_PRODUCT_SLUG; // client `product` discarded
} else {
const product = getProduct(productSlug);
if (!product) return { status: 400, message: BAD_REQUEST };
version = product.latestVersion;
effectiveProductSlug = productSlug;
}
Generalised: a query param that is not needed on a branch should not be carried along it. If a value reaches a log, a filename or a storage path without having been checked on that specific path, it is input you have stopped validating but not stopped using.
Error bodies are output, and they leak too
Every rejection above returns the same four strings:
const BAD_REQUEST = "This download link is invalid.";
const FORBIDDEN = "You don't have access to this download.";
const SERVER_ERROR = "Something went wrong. Please try again.";
const NOT_YET_AVAILABLE = "This download isn't available yet.";
An unknown slug and a known slug the caller does not own both answer with the same text, and a 404 from storage is not distinguishable from a product that was never released. Detail goes to console.error and stays server-side. Echoing the offending parameter back — Unknown product "asoc-admin" — turns one endpoint into a catalog oracle, and it is the default behaviour of most validation libraries' formatted errors.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Every param is undefined in a page component | searchParams is a promise in current Next.js | const { q } = await searchParams |
request.nextUrl is not typed | The handler takes Request, not NextRequest | Import type NextRequest from next/server and annotate the argument |
| A repeated key loses values | .get() returns only the first | Use .getAll() where multiplicity is meaningful |
?flag=false behaves as true | It is the five-character string "false" | Compare explicitly: searchParams.get("flag") === "true" |
A param with + arrives with spaces | + is an encoded space in a query string | Send %2B for a literal plus |
| Handler returns stale data for different params | The route was statically optimised | Read the request (or set export const dynamic = "force-dynamic") — see force-dynamic |
| Values look double-encoded | decodeURIComponent applied to an already-decoded value | Remove it; URLSearchParams decodes for you |
FAQ
How do I get query params in a Next.js API route?
App Router: request.nextUrl.searchParams.get("key") inside the exported GET/POST function in app/api/*/route.ts. Pages Router: req.query.key in pages/api/*. The App Router form returns string | null; the Pages Router form returns string | string[] | undefined, because it collapses repeated keys into an array — which means Pages Router code has to narrow the type before using the value, and App Router code does not.
Is request.nextUrl.searchParams different from new URL(request.url).searchParams?
For reading, no — both give you a URLSearchParams over the same query string. nextUrl is already parsed, so it avoids re-parsing and carries Next.js-specific fields such as basePath. Use nextUrl when the handler is typed NextRequest, and new URL(request.url) when you are writing a plain-Request handler that should not depend on Next.js types.
Should I validate with Zod or by hand?
Either, as long as the result is an allow-list and the error body is generic. This codebase does it by hand because the rules are two regexes and a Set built from existing data, and a schema would add a dependency without adding a check. Reach for a schema library when there are many fields or nested shapes — but turn off verbose error formatting, or your 400 responses will enumerate your parameter names and constraints for anyone probing.
Can I trust a query param that my own frontend sets?
No. The URL is editable in the address bar, so "our UI only ever sends valid slugs" is a statement about the common case, not about the input. In this handler the frontend-chosen product and framework are validated exactly as if they were hostile, and the one value that actually decides access — the user — is never in the URL at all. The same reasoning applies to ?next= redirect targets, which is the companion case in the archived Supabase Auth UI post.
Templates in this post
ASoc Amplify (a social-media-management SaaS site), ASoc Atelier (a design-studio portfolio) and ASoc Axiom (an AI-consultancy landing page) ship with the same route-handler conventions as the download endpoint above — thin handlers, validation in a pure function, generic error bodies.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the gated-download architecture this endpoint belongs to, see gated file downloads in Next.js; for when to use a handler instead of a Server Action, server actions vs API routes.
