In modern web applications, the browser address bar (URL) serves as a global, serializable, and shareable Single Source of Truth. The state of product catalog filters, search criteria, table sorting, and pagination must synchronize with URL query parameters (URLSearchParams). This ensures users can bookmark links, share them with peers, or refresh the view while preserving exact UI state.
In Next.js 15, client-side address bar interactions are coordinated through the next/navigation package:
useRouter(): Provides imperative programmatic navigation methods (push, replace, back, refresh, prefetch);useSearchParams(): Returns an immutable, read-only ReadonlyURLSearchParams instance;usePathname(): Returns the router's current pathname segment;useParams(): Returns the dynamic segment parameter map.On the server, generating search engine optimization tags and social graphs (OpenGraph, Twitter Cards) is delegated to generateMetadata(). In Next.js 15, params and searchParams supplied to generateMetadata are passed as Promise objects, necessitating updated automated testing patterns.
In the testing mental model, client-side navigation splits into two complementary data flows: reading URL state into components and dispatching updated URL mutations back to the router.
[Testing Context: Vitest + React Testing Library]
│
├─► Mock next/navigation hooks:
│ - useSearchParams() ──► new URLSearchParams('category=dev&page=2')
│ - usePathname() ──► '/catalog'
│ - useRouter() ──► { push: vi.fn(), replace: vi.fn() }
│
▼
+-------------------------------------------------------------------------------+
| Client Component: <CatalogFilters /> |
+-------------------------------------------------------------------------------+
│
├─► Read initial state: category = 'dev', page = 2
│
▼ (User changes sort order: userEvent.selectOptions)
+-------------------------------------------------------------------------------+
| Mutate URLSearchParams |
| const params = new URLSearchParams(searchParams) |
| params.set('sort', 'price_asc') |
| params.delete('page') // Reset pagination to page 1 |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| Call router.push('/catalog?' + params.toString()) |
+-------------------------------------------------------------------------------+
│
▼
[Test Assertion: expect(router.push).toHaveBeenCalledWith('/catalog?category=dev&sort=price_asc')]router.replace() with the updated ?q=query parameter while retaining all other active filters.page=1 or resets page indexes to avoid presenting empty pages.router.push('/login?returnUrl=/profile') when a client session expires.next/navigation#To cleanly isolate navigation tests, create a reusable factory allowing per-test configuration of URL states.
import { vi } from 'vitest';
export interface NavigationMockState {
pathname?: string;
searchParams?: Record<string, string>;
params?: Record<string, string>;
}
export function setupNavigationMock(initialState: NavigationMockState = {}) {
const push = vi.fn();
const replace = vi.fn();
const back = vi.fn();
const forward = vi.fn();
const refresh = vi.fn();
const prefetch = vi.fn();
const pathname = initialState.pathname ?? '/';
const urlSearchParams = new URLSearchParams(initialState.searchParams ?? {});
const routeParams = initialState.params ?? {};
const useRouterMock = vi.fn(() => ({
push,
replace,
back,
forward,
refresh,
prefetch,
}));
const useSearchParamsMock = vi.fn(() => urlSearchParams);
const usePathnameMock = vi.fn(() => pathname);
const useParamsMock = vi.fn(() => routeParams);
vi.mock('next/navigation', () => ({
useRouter: useRouterMock,
useSearchParams: useSearchParamsMock,
usePathname: usePathnameMock,
useParams: useParamsMock,
}));
return {
push,
replace,
back,
refresh,
pathname,
urlSearchParams,
useRouterMock,
useSearchParamsMock,
};
}This client component updates the sorting parameter and resets pagination while preserving all existing query parameters.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { ChangeEvent } from 'react';
export function CatalogSort() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
const nextSort = event.target.value;
// Create mutable copy of current search parameters
const nextParams = new URLSearchParams(searchParams.toString());
if (nextSort) {
nextParams.set('sort', nextSort);
} else {
nextParams.delete('sort');
}
// Changing sort order must reset page index
nextParams.delete('page');
const queryString = nextParams.toString();
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
router.push(targetUrl, { scroll: false });
};
const currentSort = searchParams.get('sort') || '';
return (
<div className="sort-wrapper">
<label htmlFor="sort-select">Sort by:</label>
<select
id="sort-select"
value={currentSort}
onChange={handleSortChange}
data-testid="sort-select"
>
<option value="">Default</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="popular">Most Popular</option>
</select>
</div>
);
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CatalogSort } from './CatalogSort';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
usePathname: vi.fn(),
}));
describe('CatalogSort (Client Navigation)', () => {
const mockPush = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
});
vi.mocked(usePathname).mockReturnValue('/catalog/phones');
});
it('updates sort param and removes page param when selection changes', async () => {
const user = userEvent.setup();
// Initial URL: /catalog/phones?brand=apple&page=3
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams('brand=apple&page=3') as unknown as ReturnType<typeof useSearchParams>
);
render(<CatalogSort />);
const select = screen.getByLabelText(/sort by/i);
expect(select).toHaveValue('');
// User selects "Price: Low to High"
await user.selectOptions(select, 'price_asc');
// Verify router.push called with preserved brand, updated sort, and removed page
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=apple&sort=price_asc',
{ scroll: false }
);
});
it('deletes sort param when default option is selected', async () => {
const user = userEvent.setup();
// Initial URL with existing sort
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams('brand=samsung&sort=popular') as unknown as ReturnType<typeof useSearchParams>
);
render(<CatalogSort />);
const select = screen.getByLabelText(/sort by/i);
expect(select).toHaveValue('popular');
// Reset to default
await user.selectOptions(select, '');
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=samsung',
{ scroll: false }
);
});
});In Next.js 15, generateMetadata() accepts route and query parameters as promises: props: { params: Promise<T>, searchParams: Promise<U> }. We test it directly as an asynchronous server function.
import { Metadata } from 'next';
import { getArticleBySlug } from '@/dal/articles';
export interface ArticlePageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ preview?: string }>;
}
export async function generateMetadata({
params,
searchParams,
}: ArticlePageProps): Promise<Metadata> {
// Asynchronous parameter resolution in Next.js 15
const { slug } = await params;
const { preview } = await searchParams;
const article = await getArticleBySlug(slug, preview === 'true');
if (!article) {
return {
title: 'Article Not Found | Fullstack Agentic Lab',
description: 'The requested material does not exist in the knowledge base.',
robots: { index: false, follow: false },
};
}
return {
title: `${article.title} | Fullstack Agentic Lab`,
description: article.summary,
alternates: {
canonical: `https://agenticlab.dev/articles/${article.slug}`,
},
openGraph: {
title: article.title,
description: article.summary,
url: `https://agenticlab.dev/articles/${article.slug}`,
type: 'article',
images: [
{
url: article.coverImage,
width: 1200,
height: 630,
alt: article.title,
},
],
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
return <article>Article {slug}</article>;
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateMetadata } from './page';
import * as articlesDal from '@/dal/articles';
vi.mock('@/dal/articles', () => ({
getArticleBySlug: vi.fn(),
}));
describe('generateMetadata (Dynamic SEO Tags)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('generates correct OpenGraph and Canonical tags for existing article', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue({
id: 'art-1',
slug: 'testing-nextjs-rsc',
title: 'Testing React Server Components',
summary: 'In-depth guide to testing asynchronous RSC and DAL in Next.js 15',
coverImage: 'https://agenticlab.dev/images/og-rsc.png',
});
const metadata = await generateMetadata({
params: Promise.resolve({ slug: 'testing-nextjs-rsc' }),
searchParams: Promise.resolve({}),
});
expect(metadata.title).toBe('Testing React Server Components | Fullstack Agentic Lab');
expect(metadata.description).toBe('In-depth guide to testing asynchronous RSC and DAL in Next.js 15');
expect(metadata.alternates?.canonical).toBe('https://agenticlab.dev/articles/testing-nextjs-rsc');
// Verify OpenGraph protocol attributes
expect(metadata.openGraph).toMatchObject({
title: 'Testing React Server Components',
type: 'article',
url: 'https://agenticlab.dev/articles/testing-nextjs-rsc',
images: [
{
url: 'https://agenticlab.dev/images/og-rsc.png',
width: 1200,
height: 630,
},
],
});
});
it('returns noindex metadata when entity does not exist', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue(null);
const metadata = await generateMetadata({
params: Promise.resolve({ slug: 'missing-article' }),
searchParams: Promise.resolve({}),
});
expect(metadata.title).toBe('Article Not Found | Fullstack Agentic Lab');
expect(metadata.robots).toEqual({ index: false, follow: false });
});
});In Next.js client components, navigation is managed by the internal router context. Mutating window.location.search = '...' or calling window.history.pushState in tests fails to trigger Next.js transition lifecycles, and in headless environments without DOM windows, it throws errors. Always mock next/navigation.
A classic flaw in filter implementations is instantiating new URLSearchParams() with only the updated key, inadvertently purging category selections, locale settings, or pagination states.
// ❌ WRONG: Wipes out all other query parameters
const nextParams = new URLSearchParams({ sort: 'asc' });
// ✅ CORRECT: Clone current searchParams before mutation
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('sort', 'asc');In Next.js 15, the generateMetadata({ params, searchParams }) signature provides properties as Promises. Attempting to destructure { slug } directly off params results in undefined.
The useRouter() hook is exclusively available in Client Components ('use client'). In Server Components, redirects must be executed by invoking the server redirect('/target') function from next/navigation.
Define baseline query parameters (searchParams) and pathnames via vi.mock.
Mount search form or filter controls using React Testing Library.
Trigger sort change, text input, or pagination clicks via userEvent.
Verify that target URL preserves existing parameters while appending updated query keys.
Confirm that page indexes are cleared upon primary filter changes.
Invoke server metadata generators with async params and assert resulting SEO tags.