Skip to main content
ASoc
Tutorial

Next.js: Get Query Params on the Server (and Validate Them)

Where searchParams is available in the App Router, where it is not, and the one-line guard that stops ?next= becoming an open redirect.

The ASoc Team7 min read

In the App Router you read query params on the server in three places: the searchParams prop on a page.tsx (a Promise, so await it), request.nextUrl.searchParams in a Route Handler, and new URL(request.url) when you only have a plain Request. Layouts get none of them. Whatever you read is user input, so validate it before it reaches a redirect, a query or a filename.

Which one to use

This site reads query strings in four server files. They cover every case you will meet:

Where you areHow you read itType you getFile in this repo
A page.tsxsearchParams prop, awaitedPromise<{ key?: string }>src/app/dashboard/page.tsx, src/app/login/page.tsx
A Route Handlerreq.nextUrl.searchParamsURLSearchParamssrc/app/api/download/route.ts
A Route Handler on a plain Requestnew URL(request.url).searchParamsURLSearchParamssrc/app/auth/callback/route.ts
A layout.tsxNot availablen/aMove the read into the page
A client componentuseSearchParams() from next/navigationRead-only URLSearchParamsNot needed here

Two things change relative to the Pages Router, where you read context.query inside getServerSideProps. The value is a Promise in a page, and a page that reads it renders per request instead of at build time. If you are porting an old page, the getServerSideProps mapping covers the rest of the migration. This post is only about the query string.

In a page: await the prop

The login page takes two optional params and nothing else:

// 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);

The dashboard does the same with { verification?: string; tab?: string }. Three details are worth knowing.

Every value is string | string[] | undefined. ?tab=a&tab=b arrives as an array. The { tab?: string } annotation above is a hope, not a guarantee. If a repeated key would break you, normalise it before use: const tab = Array.isArray(t) ? t[0] : t.

Destructuring with a rename is fine. next is a reserved-feeling word in Next.js code, so the login page binds it as nextParam and keeps next for the validated result.

Reading the prop makes the route dynamic. /login and /dashboard render on each request. The static routes in this repo (/, /templates/[slug], /pricing) read no request data, so they stay prerendered. If you only need the param for a client-side filter, read it in a client component with useSearchParams() instead and keep the page static. That is the trade the product filtering post makes.

In a Route Handler: nextUrl already parsed it

The download endpoint reads two params and hands them to a function that does the checking:

// 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,
);

get() returns string | null, never undefined, and it returns the first value if the key repeats. Pass the null through; do not coerce it to an empty string. The next section shows why.

The auth callback receives a plain Request, so it parses the URL itself:

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

new URL(request.url) gives you origin for free, which a redirect needs. req.nextUrl is the typed shortcut when your handler is declared with NextRequest.

Validate before you use it

A query param is a string an attacker can write. This site has three sinks for them, and each has a guard that runs before the value goes anywhere.

A redirect target. ?next= tells the login page where to send you afterwards. Unchecked, ?next=//evil.com is an open redirect: browsers read a leading // as protocol-relative. The guard is one regex:

// src/lib/validation.ts
const SAFE_NEXT_RE = /^\/(?![/\\])/;

export function safeNext(next: string | null | undefined): string {
  return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}

It accepts a path starting with a single / and rejects //host and /\host. The backslash case matters because the WHATWG URL parser treats \ as /, so /\evil.com becomes an off-site redirect once a client calls router.push on it. The same function runs in the login page, the callback route and both auth server actions in src/lib/actions/auth.ts.

A storage key. ?product= and ?framework= become part of a file path in the private releases bucket. resolveDownload in src/lib/download.ts rejects a missing value, a slug that fails isValidSlug, and any framework outside a fixed set, all with the same generic 400 message. Missing and invalid return the same body on purpose, so the endpoint does not describe what it accepts. The entitlement check runs after that, and only then does a signed URL get minted.

Rendered output. React escapes strings in JSX, so ?error=<script> renders as text. The risk is dangerouslySetInnerHTML or building a URL by hand, and the only two uses of dangerouslySetInnerHTML under src/app and src/components (the root layout and the JsonLd atom) emit build-time JSON, never request data.

A checklist before you ship a param

Run each query param through the same four questions. It takes a minute and it is where the open redirect above would have been caught.

  1. What type does the code assume? A string, a number, a slug? Convert and check it at the edge, not three functions down.
  2. What happens when it is missing? The login page falls back to /dashboard; the download route returns a 400. Pick one on purpose.
  3. Where does it end up? A redirect, a database filter, a file path and a rendered string each need a different guard.
  4. Is there a test for the hostile value? If the guard is a pure function, the test is five lines.

Test the guard, not the page

The cheapest place to pin this behaviour is the pure function. src/lib/__tests__/validation.test.ts has five cases for safeNext, including the encoded-backslash case, the easiest one to miss:

it("rejects the URL-encoded backslash form, exactly as it arrives decoded from searchParams", () => {
  const decoded = decodeURIComponent("/%5Cevil.com");
  expect(decoded).toBe("/\\evil.com");
  expect(safeNext(decoded)).toBe("/dashboard");
});

The test decodes first because searchParams has already decoded the value by the time your code sees it. Testing the raw %5C form would pass for the wrong reason. The whole file runs in vitest with no browser and no server, which is the right cost for a check like this. For where that approach stops working, see Cypress vs Vitest.

Troubleshooting

SymptomCauseFix
searchParams.next is undefined and TypeScript is silentYou forgot await. The prop is a Promise in Next 16const { next } = await searchParams
Page shows stale output for different paramsNothing in it read request data, so it was prerenderedRead searchParams in the page, or export dynamic = "force-dynamic"
searchParams is not a prop of your layoutLayouts do not receive itRead it in the page, or useSearchParams() in a client component
?tab=a&tab=b crashes a .toLowerCase() callThe value is an arrayNormalise to the first element before use
+ becomes a space in a valueStandard query decodingSend %2B, or build links with encodeURIComponent
Login loop after you add ?next=The target is rejected by the guard and falls backPass a single-slash relative path only
useSearchParams() forces a Suspense error at buildA static page reads it in a client componentWrap that component in <Suspense>

Frequently asked questions

Is searchParams a Promise in every Next.js version? Not in older ones. Version 15 introduced the async form and 16 requires it. The code in this post targets 16, where await searchParams is the only supported form.

Can I read query params in a Server Component that is not a page? Not through a prop. Only the page receives searchParams. Pass the value down as a prop, or read it in a client child with useSearchParams().

Will reading searchParams hurt performance? It moves that route from prerendered to per-request, which is the cost of showing per-request content. Keep the dynamic read on the routes that need it and leave marketing pages static.

Do I need a library like Zod to validate them? Not for two or three params. A small guard like safeNext is easier to test and read. A schema pays off once a page takes many typed filters.

Templates in this post

ASoc Coin (an online-banking marketing site), ASoc Compound (an automated-investing site) and ASoc Cortex (an AI-agency site) each ship a Next.js edition, so you can build the pattern above on a finished layout.

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

Keep reading

Tutorial13 min read

Next.js Image Optimization Without next/image: 255 KiB to 33 KiB

A closed image set does not need a request-time optimiser. The sharp build script, the plain-img markup, and the lazy LCP image that cost us 1.67s of load delay.

Read more