D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`cat.trim is not a function` From `?cat=a&cat=b`? Next.js `searchParams` Holds an Array, Not a String

· · 10 min read
`cat.trim is not a function` From `?cat=a&cat=b`? Next.js `searchParams` Holds an Array, Not a String

The helper behind this fix carries a comment with one uncomfortable sentence in it: before the helper existed, anyone could break four pages on the site with nothing more than a URL. All it took was one query key written twice, such as ?cat=a&cat=b.

The problem came in through the security hardening list for a Next.js site I am working on. A read-only re-verification on 30 September recorded 45 root issues, 4 already closed and 41 still open, and ranked the next eight. The 1 October handover put the URL values group in second place, noted that nothing in that group needed sign-off, and suggested it as the starting point. It was a mixed bag, from allowlists for ?cat= and ?chain= to one small helper for reading query values. At 14:10 WIB on 5 October I decided that items 5 and 6 from that group would be done right away and the rest written down for later. Item 5 is the repeated query key. Item 6 is a separate fix I am not covering here.

Reproducing it on staging

Before changing anything, I reproduced it on staging. Four listing pages rendered their error state as soon as their filter key was repeated: a directory filtered by category through cat, a term index filtered by letter through letter, and two paginated listings, one filtered through cat and the other through chain. Two other URLs with a repeated key stayed safe, a chart range on a detail page and the page number on the article listing. The paths below are disguised, and the queries are exactly as tested:

# staging, before the fix (paths disguised, queries as tested)
/directory?cat=a&cat=b        error state, HTTP 200, error digest present
/entries?letter=a&letter=b    error state, HTTP 200, error digest present
/list-a?cat=a&cat=b           error state, HTTP 200, error digest present
/list-b?chain=a&chain=b       error state, HTTP 200, error digest present
/item/x?range=7d&range=30d    safe
/posts?page=2&page=3          safe

What makes this one slippery is the shape of the failure. The error state went out with HTTP 200 and carried an error digest, so judged by status code, everything looked healthy. In a production build Next.js does not send the original error message from a server component to the browser, only a digest to match against the server logs. From the outside, what changed was the content: a digest in the response, and a smaller page. The error state responses were 37 to 65 KB, where a healthy page is 200 KB or more. Worked out, 37 divided by 200 is about 0.19 and 65 divided by 200 is about 0.33, so a broken page came in at about a third of its normal size at most.

A string annotation that was not honest

The same helper comment also explains the root cause: Next hands a page ?cat=a&cat=b as the array ["a", "b"]. That is not a Next bug. Its own documentation types every searchParams value as string | string[] | undefined, and this project runs on Next.js 16.3.6. What was wrong was the type the four pages wrote for themselves, which said the value was always a string. The lines below are quoted as they were before the fix, and the comments at the end of each line are mine, added to show where each one comes from:

// before the fix: the type on the first paginated listing
type Search = { page?: string; cat?: string };
 
// one line from each page that broke
const filter = cat?.toLowerCase();                             // directory
letterParam && grouped.has(letterParam.toUpperCase())          // term index
const category = cat?.trim() || undefined;                     // first listing
const currentPage = cat?.trim() ? 1 : parsePage(page);         // first listing, generateMetadata
const chainFilter = chain?.trim().toLowerCase() || undefined;  // second listing

As long as the type says string, TypeScript has no reason to complain, and type annotations are gone at runtime anyway. Once the key repeats, what arrives is an array, arrays have no trim method, and the error the helper's comment records reads cat.trim is not a function. The fix's commit message sums up all four pages with the same pattern: the value was typed as a string and trim(), toLowerCase() or toUpperCase() was called on it directly, so a repeated key threw and the page rendered its error state. On the first listing, trim() was called in the page body and once more in generateMetadata.

The two places that were already safe

The two URLs that stayed safe turned out to say more than the four that broke. The page number parser on the article listing returns 1 for anything that is not a string holding a plain positive integer, and the chart range check compares the value with === against a list of known ranges. The constant name is disguised, and everything else is as written:

// page number parser on the article listing
if (typeof raw !== "string" || !/^[1-9][0-9]*$/.test(raw)) return 1;
 
// chart range check on the detail page
return RANGES.some((r) => r.value === v);

The first parser checks typeof before touching the value, and its comment even names a repeated param explicitly as a value that reads as page 1, alongside "0", "-3", "2.5", "03" and "abc". The second check does not need to know anything about arrays. An array that comes from the URL is a fresh object, and === never treats it as equal to any value on the list, so a repeated range fails on its own. The fix's commit message notes that the article listing, the category listing and the range on the detail page already tolerated arrays, so all three were left untouched.

The pattern is simple. Code that checked the shape of a value before using it survived, while code that trusted the annotation and called a string method straight away broke the moment the shape changed.

A helper that only looks at the first value

The fix is one small helper with its own tests, used in all four page files that broke. The function name and signature are disguised and simplified here, while the maxLength default, the first line and the typeof check inside are verbatim:

// simplified: function name and signature disguised,
// the maxLength default, the first line and the typeof check are verbatim
export function firstParam(
  value: string | string[] | undefined,
  maxLength = 120,
): string | undefined {
  const first: unknown = Array.isArray(value) ? value[0] : value;
  if (typeof first !== "string" || first.length > maxLength) return undefined;
  return first;
}

If the value is an array, the helper takes the first element. If not, the value is used as it is. The result is undefined when that first value is not a string or is longer than 120 characters. The helper's comment makes the point that a value past the limit is treated as absent rather than cut short, on the grounds that no real filter on the site comes near the limit. To the caller, undefined has the same shape as a parameter that was never sent at all.

Three lines from its tests state the contract more firmly than any description could:

// three lines from the helper's tests, function name disguised
expect(firstParam(["a", "b"])).toBe("a");
expect(firstParam(["a".repeat(500), "ok"])).toBeUndefined();
expect(() => firstParam(raw)?.trim().toLowerCase()).not.toThrow();

The second line is the one that counts. A first value of 500 characters gives undefined even though the second value is "ok", because the helper does not go looking for a usable value among the rest. It only looks at the first one. The third line runs for every input in the test list and makes sure the trim().toLowerCase() chain that used to break no longer throws. According to the commit message, cat, chain and letter are read through this helper on all four pages, and a single value still behaves exactly as before, canonical and title included.

The first draft also read page

The first draft used the same helper for one more parameter. A short note at 14:30 WIB, written before the review results came in, lists the helper for cat, chain, letter and page. On a local production build, that draft already rendered the four repeated-key URLs with the right canonical and no error digest.

The draft then went through an adversarial review workflow with three read-only reviewers: bypass, regression and quality. The bypass reviewer approved, the other two asked for changes, and no bug was found in either of the two fixes under review. One of the changes applied afterwards actually undid part of the draft: a repeated page on the two paginated listings reads as page 1 again, because the first draft moved its canonical, and that was an SEO change nobody had asked for.

Why would the way a page number is read move a canonical? Because on those two listings the canonical depends on the page number. On 16 September the client's SEO contact asked for the pagination on both listings to be self-canonical, so Google could reach the entry ranked 4,950 through page 50. The release that day made page N self-canonical, while views filtered by cat or chain point their canonical at the bare listing. Under a rule like that, changing how page is read is the same as changing the canonical, and a fix for a crash has no business moving it.

The committed version makes the type honest without changing the behavior:

// first paginated listing, after the fix
type Search = { page?: string | string[]; cat?: string | string[] };
 
// inside parsePage, on both listings
if (typeof raw !== "string") return 1;
 
// the page number is passed through untouched
const currentPage = parsePage(search.page);

The commit message states the decision plainly: a repeated page key never threw and keeps reading as page 1, the rule the article listings follow, and only its type changed, which is now honest. With string | string[] as the type, TypeScript also stops letting cat?.trim() through without narrowing, because trim does not exist on string[].

The release and how far the checks reach

A clean export of HEAD plus this change passed 745 tests, tsc, eslint and the build, and when served locally, 21 URLs came out as expected. At 15:00 WIB I decided to commit and release to production, with a commit kept separate from item 6. The fix landed on develop as its own commit, and the committed tree was the same tree that had been tested. Staging was checked with a check script, and the release to main followed. On production, 21 URLs render, and ?page=2&page=3 becomes page 1 with the bare canonical. The note at 15:18 WIB marks items 5 and 6 as released and verified, and a day later, when staging took a release of another fix, the same script gave the same result as before.

There is one limit I should state plainly. The observation that names the four repeated-key URLs explicitly, rendering with the right canonical and no error digest, was recorded on a local production build of the first draft. For production, the notes only summarize 21 URLs rendering without listing which ones, so the only repeated-key URL recorded explicitly on production is ?page=2&page=3.

The helper is not an allowlist

The helper is narrow, and it should be. It makes sure a page receives one string of reasonable length or nothing at all, not that the string names a category that actually exists. Limits on the content came in a separate release three days later: from 8 October, ?cat= and ?chain= are only accepted from known lists, and filtered views use page 1. That allowlist had been listed in the same group since the 1 October handover, right next to this helper.

If there is one habit worth taking from this item, it sits on the line between the URL and the code. A value from searchParams does not deserve to be called a string until something has checked it, and the cheapest place to check is a single helper at the entrance, not trim() calls scattered through the page body. An honest string | string[] type lets the compiler stand guard too. An error state sent with HTTP 200 will not be caught by a check that only looks at status codes. On this site it showed up as a page shrinking to roughly a third of its size or less, and as a digest in the response. When a fix touches a parameter that decides the canonical, treat that as an SEO change someone has to ask for, not a side effect allowed to ride along.