javascript
53 lines · 10 steps
Parsing search params in a Next.js page
A Next.js server component sanitizes URL query params into a typed filter object before querying products.
Explained by
highlit
1import { notFound } from 'next/navigation';
2
3const PER_PAGE = 24;
4const SORT_OPTIONS = ['relevance', 'price-asc', 'price-desc', 'newest'];
5
6function parseFilters(searchParams) {
7 const q = typeof searchParams.q === 'string' ? searchParams.q.trim() : '';
8
9 const sort = SORT_OPTIONS.includes(searchParams.sort)
10 ? searchParams.sort
11 : 'relevance';
12
13 const page = Math.max(1, Number.parseInt(searchParams.page, 10) || 1);
14
15 const min = Number.parseFloat(searchParams.min);
16 const max = Number.parseFloat(searchParams.max);
17 const minPrice = Number.isFinite(min) && min >= 0 ? min : null;
18 const maxPrice = Number.isFinite(max) && max >= 0 ? max : null;
19
20 const categories = []
21 .concat(searchParams.category ?? [])
22 .filter((c) => typeof c === 'string' && c.length > 0);
23
24 const inStock = searchParams.inStock === 'true';
25
26 return { q, sort, page, minPrice, maxPrice, categories, inStock };
27}
28
29export default async function ProductsPage({ searchParams }) {
30 const filters = parseFilters(await searchParams);
31
32 if (filters.minPrice !== null && filters.maxPrice !== null && filters.minPrice > filters.maxPrice) {
33 notFound();
34 }
35
36 const { products, total } = await searchProducts({
37 ...filters,
38 limit: PER_PAGE,
39 offset: (filters.page - 1) * PER_PAGE,
40 });
41
42 if (filters.page > 1 && products.length === 0) {
43 notFound();
44 }
45
46 return (
47 <ProductGrid
48 products={products}
49 filters={filters}
50 totalPages={Math.ceil(total / PER_PAGE)}
51 />
52 );
53}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1User-supplied query params are untrusted strings, so coerce and bound each one before using it.
- 2Centralizing parsing in one function gives every downstream consumer a clean, predictable filter shape.
- 3Returning notFound() for impossible or empty states keeps invalid URLs from rendering broken pages.
Related explainers
javascript
const ROLE_PERMISSIONS = { admin: ['users:read', 'users:write', 'billing:read', 'billing:write'], manager: ['users:read', 'billing:read'], member: ['users:read'],
Role-based permissions middleware in Express
authorization
middleware
rbac
Intermediate
9 steps
javascript
function attachThousandSeparators(input, { locale = 'en-US' } = {}) { const formatter = new Intl.NumberFormat(locale); const groupSep = formatter.format(11111).replace(/\d/g, '')[0] || ','; const decimalSep = formatter.format(1.1).replace(/\d/g, '')[0] || '.';
Live thousand separators without losing the caret
dom
intl
caret-preservation
Advanced
8 steps
javascript
import { useReducer, useEffect } from "react"; const initialState = { status: "idle", data: null, error: null };
Building a data-fetching hook in React
custom-hooks
usereducer
data-fetching
Intermediate
9 steps
ruby
module Paginatable extend ActiveSupport::Concern private
A reusable pagination concern in Rails
pagination
concerns
http-headers
Intermediate
8 steps
javascript
const express = require('express'); const app = express(); app.get('/health', (req, res) => res.json({ status: 'ok' }));
Graceful shutdown in an Express server
graceful-shutdown
signal-handling
connection-tracking
Advanced
9 steps
go
func UploadChunk(c *gin.Context) { uploadID := c.Param("uploadID") if !validUploadID.MatchString(uploadID) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload id"})
Resumable chunked uploads in Gin
file-upload
content-range
streaming
Advanced
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/parsing-search-params-in-a-next-js-page-explained-javascript-20a1/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.