params vs searchParams
A user filters your product list, finds exactly what they want, copies the URL to share with a friend... and it shows the unfiltered list. Or worse: /products?id=123 works today but breaks when you add categories.
URLs have two jobs: identity (which resource?) and state (how should it display?). Mixing them up creates brittle routes and broken bookmarks. This lesson teaches the clean separation that makes URLs shareable, cacheable, and refactor-proof.
Outcome
Pages that read params and searchParams correctly with stable types.
Fast Track
- Add a page reading
paramsfor identity (async await required). - Add controls that push
searchParamsfor filters/sorts (async await required). - Keep concerns separate (identity vs stateful UI).
Hands-On Exercise 2.7
Build URL-driven state with proper separation of concerns.
Requirements:
- Implement a listing page driven by
searchParams(filters, sorting, pagination). - Keep detail page identity in
params(e.g., blog post slug). - Demonstrate both in blog routes.
- Show how to update search params without losing existing filters.
Implementation hints:
- params and searchParams are Promises: Both must be awaited before use. Page components must be async.
- TypeScript types: Type params as
Promise<{ slug: string }>and searchParams asPromise<{ category?: string }>, etc. - Parallel awaiting: When you need both params and searchParams, use
Promise.all([params, searchParams])for optimal performance. - params for route segments: Dynamic segments like
[slug]become params, used for resource identity (e.g., blog post, product ID). - searchParams for query strings: Everything after
?in URL, used for filters, search queries, pagination, view preferences. - URL state preservation: Makes filtered results bookmarkable and shareable.
- Filter patterns: Use
searchParamsfor category, sort, page, query filters. - Reset pagination: When filters change, reset
pageto 1 to avoid empty results. - Server Components: params and searchParams passed as props automatically, perfect for initial data fetching (remember to await).
- Client Components: Use
useSearchParamsto read,useRouterto update dynamically.
- Avoid encoding identity in query strings.
- Keep URLs meaningful and shareable.
- Preserve existing search params when updating (use
URLSearchParams).
Caching Impact
Key insight: params enable caching, searchParams prevent it.
┌─────────────────────────────────────────┐
│ PARAMS vs SEARCHPARAMS CACHING │
├─────────────────────────────────────────┤
│ params (path segments) │
│ └─ Static by default (○) │
│ └─ Can prerender with generateStaticParams │
│ │
│ searchParams (query string) │
│ └─ Always dynamic (ƒ) │
│ └─ Cannot be prerendered │
└─────────────────────────────────────────┘
| URL Part | Build Output | Caching Behavior | Use For |
|---|---|---|---|
params ([slug]) | ○ (Static) | Can prerender per value | Resource identity |
searchParams (?sort=) | ƒ (Dynamic) | Renders per request | Filters, pagination |
Cache by Params Strategy
When you need both caching and filtering, cache the expensive data by params and keep searchParams for lightweight filtering:
// cacheTag and cacheLife are Next.js 16 cache APIs - you'll learn these in Lesson 3.1
import { cacheTag, cacheLife } from 'next/cache'
// Cached data function - keyed by slug (params)
async function getPost(slug: string) {
"use cache"
cacheTag(`post-${slug}`)
cacheLife('hours')
// Expensive database query - cached per slug
return db.posts.findUnique({ where: { slug } })
}
export default async function BlogPost({
params,
searchParams
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ highlight?: string }>
}) {
const [{ slug }, { highlight }] = await Promise.all([params, searchParams])
// Post data is cached (keyed by slug)
const post = await getPost(slug)
// Highlighting is dynamic (depends on searchParams)
// but doesn't require a database query
return (
<article>
<h1>{post.title}</h1>
<Content text={post.content} highlight={highlight} />
</article>
)
}
// Prerender known slugs at build time
export async function generateStaticParams() {
const posts = await db.posts.findMany({ select: { slug: true } })
return posts.map((post) => ({ slug: post.slug }))
}The pattern:
- Identity data (product ID, post slug) →
params→ cacheable withgenerateStaticParams - State data (filters, sort, page, highlight) →
searchParams→ dynamic but lightweight - Expensive operations → Cache by
params, not affected bysearchParams
This separation lets you cache the expensive parts (database queries) while keeping filters dynamic.
Code Examples
Dynamic Route with params
export default async function BlogPost({
params
}: {
params: Promise<{ slug: string }>
}) {
// Await params before accessing values
const { slug } = await params
// Fetch data using the slug
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}Listing Page with searchParams
export default async function BlogListing({
searchParams
}: {
searchParams: Promise<{
category?: string
page?: string
sort?: string
}>
}) {
// Await searchParams before accessing values
const { category, page, sort } = await searchParams
// Use searchParams for filtering and pagination
const posts = await getPosts({
category,
page: parseInt(page || '1'),
sort: sort || 'recent'
})
return (
<div>
<h1>Blog Posts {category && `in ${category}`}</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
)
}Combined params + searchParams (Parallel Await)
export default async function CommentsPage({
params,
searchParams
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ sort?: string; filter?: string }>
}) {
// Parallel await for optimal performance
const [{ slug }, { sort, filter }] = await Promise.all([
params,
searchParams
])
// Both values are now available
const comments = await getComments(slug, {
sort: sort || 'recent',
filter
})
return (
<div>
<h1>Comments for {slug}</h1>
{comments.map(comment => (
<div key={comment.id}>{comment.text}</div>
))}
</div>
)
}TypeScript Types Pattern
// Extract types for reuse
type BlogPostParams = Promise<{ slug: string }>
type BlogSearchParams = Promise<{
category?: string
page?: string
sort?: 'recent' | 'popular' | 'oldest'
}>
export default async function Page({
params,
searchParams
}: {
params: BlogPostParams
searchParams: BlogSearchParams
}) {
const { slug } = await params
const { category, page, sort } = await searchParams
// ...
}Try It
- Share a filtered URL and confirm state is preserved.
Commit & Deploy
git add -A
git commit -m "feat(core): implement params identity + searchParams filters"
git push -u origin feat/core-params-searchparamsDone-When
- Navigate to
/hello-worldin blog app: blog post content displays (params for identity) - Navigate to blog listing, click "Tech" filter: URL updates to
?category=techand list filters - Copy the filtered URL, open in new tab: same filtered results appear (bookmarkable state)
- Change sort dropdown: URL updates to include
?sort=title, existingcategoryparam preserved - Click "Clear filters": URL returns to base path with no query string
- Code review: params and searchParams both typed as
Promise<...>and awaited
Solution
Solution
Install Dependencies
No additional dependencies needed for basic params/searchParams. For URL validation, consider:
pnpm add zodBlog Post Detail Page (params)
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
// Mock data fetcher
async function getPost(slug: string) {
const posts: Record<string, { title: string; content: string }> = {
'hello-world': { title: 'Hello World', content: 'Welcome to our blog!' },
'nextjs-routing': { title: 'Next.js Routing', content: 'Learn about App Router.' },
}
return posts[slug] || null
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-3xl font-bold">{post.title}</h1>
<p className="text-gray-600">{post.content}</p>
</article>
)
}Blog Listing Page (searchParams)
import Link from 'next/link'
import { FilterControls } from './filter-controls'
type Props = {
searchParams: Promise<{
category?: string
sort?: string
page?: string
}>
}
// Mock data
const allPosts = [
{ id: '1', slug: 'hello-world', title: 'Hello World', category: 'general' },
{ id: '2', slug: 'nextjs-routing', title: 'Next.js Routing', category: 'tech' },
{ id: '3', slug: 'react-tips', title: 'React Tips', category: 'tech' },
]
export default async function BlogListingPage({ searchParams }: Props) {
const { category, sort, page } = await searchParams
// Filter and sort posts
let posts = category
? allPosts.filter((p) => p.category === category)
: allPosts
if (sort === 'title') {
posts = [...posts].sort((a, b) => a.title.localeCompare(b.title))
}
const currentPage = parseInt(page || '1', 10)
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-bold">
Blog Posts {category && <span className="text-gray-500">in {category}</span>}
</h1>
{/* Client Component for filter controls */}
<FilterControls currentCategory={category} currentSort={sort} />
<ul className="mt-6 space-y-4">
{posts.map((post) => (
<li key={post.id}>
<Link
href={`/${post.slug}`}
className="block rounded-lg border p-4 hover:bg-gray-50"
>
<h2 className="font-semibold">{post.title}</h2>
<span className="text-sm text-gray-500">{post.category}</span>
</Link>
</li>
))}
</ul>
<p className="mt-4 text-sm text-gray-500">Page {currentPage}</p>
</div>
)
}Client-Side Filter Controls (useSearchParams + useRouter)
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useCallback } from 'react'
type Props = {
currentCategory?: string
currentSort?: string
}
export function FilterControls({ currentCategory, currentSort }: Props) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
// Create a new URLSearchParams instance preserving existing params
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value) {
params.set(name, value)
} else {
params.delete(name)
}
// Reset pagination when filters change
if (name !== 'page') {
params.delete('page')
}
return params.toString()
},
[searchParams]
)
const handleCategoryChange = (category: string) => {
router.push(`${pathname}?${createQueryString('category', category)}`)
}
const handleSortChange = (sort: string) => {
router.push(`${pathname}?${createQueryString('sort', sort)}`)
}
const clearFilters = () => {
router.push(pathname)
}
return (
<div className="flex flex-wrap gap-4">
{/* Category filter */}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleCategoryChange('')}
className={`rounded px-3 py-1 text-sm ${
!currentCategory ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => handleCategoryChange('tech')}
className={`rounded px-3 py-1 text-sm ${
currentCategory === 'tech' ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}
>
Tech
</button>
<button
type="button"
onClick={() => handleCategoryChange('general')}
className={`rounded px-3 py-1 text-sm ${
currentCategory === 'general' ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}
>
General
</button>
</div>
{/* Sort control */}
<select
value={currentSort || ''}
onChange={(e) => handleSortChange(e.target.value)}
className="rounded border px-3 py-1 text-sm"
>
<option value="">Default order</option>
<option value="title">Sort by title</option>
</select>
{/* Clear all filters */}
{(currentCategory || currentSort) && (
<button
type="button"
onClick={clearFilters}
className="text-sm text-red-600 hover:underline"
>
Clear filters
</button>
)}
</div>
)
}Key Patterns
-
params for identity: Dynamic route segments (
[slug]) become params. Always await before use. -
searchParams for state: Query string values for filters, sort, pagination. Also awaited in Server Components.
-
useSearchParams for client updates: Read current params, create new URLSearchParams, preserve existing values.
-
Reset pagination on filter change: Delete
pageparam when changing other filters to avoid empty results. -
Shareable URLs: Users can bookmark
/blog?category=tech&sort=titleand share the exact filtered view.
References
Was this helpful?