Dynamic Routes, Params, and Search Params in Next.js


Dynamic routes turn a file-system convention into a resource boundary. A segment such as app/products/[slug]/page.tsx identifies which product a request addresses. Search parameters describe how that resource should be viewed: ?view=grid, ?sort=price, or ?page=2. Keeping those jobs separate makes URLs shareable, caching easier to reason about, and data access safer.
Two Kinds of URL State
Use a dynamic segment for identity that belongs in the route hierarchy. Use a search parameter for optional request state that can change without redefining the resource.
/products/shipping-labels?view=grid&page=2
└───── slug ─────┘ └── request state ──┘type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ view?: string; page?: string }>;
};
export default async function ProductPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const { view, page } = await searchParams;
// Parse, validate, then fetch only the addressed product.
}Validate at the Route Boundary
Treat URL input as untrusted input. A path segment is not proof that a record exists or that the requester is authorized to see it. Validate its shape, load the resource through a scoped data function, and return notFound() where absence is the correct product response.
function parsePage(value: string | undefined): number {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page : 1;
}A Valid URL Is Not Authorization
Validate the parameter format, then authorize access against the loaded record. Never use an opaque route value as permission to return customer data.
Validate structure, then validate meaning
These are two different checks. Structural validation asks whether the input can
be interpreted: is page a positive integer, is view one of the views the
screen supports, and does slug match the identifier format the application
uses? Semantic validation happens after loading: does that product exist, is it
published, and may this visitor see the selected account's data?
Keep the conversion close to the route. It gives the rest of the page a useful
contract instead of spreading string | undefined through data and UI code.
const views = ["grid", "list"] as const;
type View = (typeof views)[number];
function parseView(value: string | undefined): View {
return views.includes(value as View) ? (value as View) : "grid";
}
function parseProductSlug(value: string): string {
if (!/^[a-z0-9-]+$/.test(value)) notFound();
return value;
}There is an important product decision in every fallback. A malformed page
number can reasonably become page one. An unsupported view can become the
default presentation. A non-existent product should usually become notFound()
rather than a page that looks successful but contains no meaningful content.
Choose those behaviours deliberately; do not let JavaScript coercion choose for
you.
Keep Search Params Intentional
Search params are excellent for state a user should bookmark, share, or revisit: filters, sorting, pagination, and presentation choices. They are poor storage for secrets, large serialized objects, and short-lived local input. Keep default values explicit and normalize invalid values so two equivalent URLs do not produce two subtly different screens.
Inspect a route boundary
This browser simulation shows the separate jobs of a dynamic segment and a search parameter. It does not perform a real Next.js navigation.
Route-state simulator
Browser simulation: edit a path parameter and a search parameter.
/products/shipping-labels?view=grid[slug] selects the route segment; view is request-specific URL state. Validate both before using them for data access or rendering.
export default async function Page({ params, searchParams }: Props) {
const { slug } = await params;
const { view = 'grid' } = await searchParams;
return <Product slug={slug} view={view} />;
}A small URL schema makes the page easier to change
As a route grows, collect its URL parsing in one named function. This is less about adding abstraction than making the public surface of the screen visible. The parser becomes a compact inventory of every state a reader can encode in a link, and it becomes straightforward to test with ordinary objects.
type CatalogQuery = {
page: number;
view: "grid" | "list";
sort: "featured" | "price";
};
function parseCatalogQuery(
input: Record<string, string | string[] | undefined>
): CatalogQuery {
const value = (key: string) => {
const candidate = input[key];
return Array.isArray(candidate) ? candidate[0] : candidate;
};
return {
page: parsePage(value("page")),
view: parseView(value("view")),
sort: value("sort") === "price" ? "price" : "featured",
};
}Notice the policy for repeated keys. Browsers can send ?view=grid&view=list.
You might reject that in a security-sensitive API; for a catalog, taking the
first value and normalizing to one canonical URL is often friendlier. The
important part is that the policy is explicit and consistent.
Fetch From a Validated Route Contract
Do not hand raw search parameters directly to a database query. Parse them into the finite values your data layer understands, then make the fetch function describe those values in its type. This keeps query construction constrained even when the page later gains filters or an alternate renderer.
import { notFound } from "next/navigation";
export default async function ProductPage({ params, searchParams }: PageProps) {
const { slug: rawSlug } = await params;
const query = parseCatalogQuery(await searchParams);
const slug = parseProductSlug(rawSlug);
const product = await getPublishedProduct({ slug });
if (!product) notFound();
return <ProductDetail product={product} relatedView={query.view} />;
}The exact data boundary differs by application. A public storefront may build
getPublishedProduct so unpublished records are impossible to return. A
workspace app may pass an authenticated organization id as well, so a syntactic
slug cannot cross a tenant boundary. That scope belongs in the data function,
not only in a conditional rendered after the fetch.
Build Links as State Transitions
When a user changes one filter, preserve the other meaningful state. Build the next URL from current URLSearchParams, update one key, and reset dependent pagination deliberately. This prevents a view toggle from silently deleting a carefully selected filter.
const next = new URLSearchParams(current);
next.set("view", "list");
next.set("page", "1");
router.push(`?${next.toString()}`);Use client navigation for an interaction that changes URL state, but keep the page's canonical parsing and data access on the server. The URL remains the source of truth after refresh, sharing, and direct entry.
Use a helper for repeatable updates
The most common URL-state bug is accidental loss: a component changes sorting and forgets the active filter; a filter changes and leaves the user stranded on page seven. Put that policy in a tiny helper so each control produces the same kind of transition.
function updateQuery(
current: URLSearchParams,
changes: Record<string, string | undefined>
) {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(changes)) {
if (value === undefined) next.delete(key);
else next.set(key, value);
}
return next.toString();
}
// A new sort restarts pagination, but preserves all unrelated state.
router.push(`?${updateQuery(current, { sort: "price", page: "1" })}`);For UI state that does not deserve a bookmark—an open popover, an unsaved form, or temporary pointer position—use component state instead. The URL is a public interface. Treat every key placed in it as something a person may copy, revisit, and reasonably expect to keep working after the implementation changes.
Test the Contract, Not Only the Clicks
Route parsing is ideal for table-driven tests because its important behaviour is deterministic. Test defaults, valid values, malformed values, and repeated keys. Then use one end-to-end test to confirm that a representative link renders the expected screen. This combination catches URL regressions without making the entire suite depend on browser interactions.
it.each([
[{}, { page: 1, view: "grid", sort: "featured" }],
[
{ page: "3", view: "list" },
{ page: 3, view: "list", sort: "featured" },
],
[
{ page: "zero", view: "masonry" },
{ page: 1, view: "grid", sort: "featured" },
],
])("normalizes catalog query %#", (input, expected) => {
expect(parseCatalogQuery(input)).toEqual(expected);
});Canonical links are a useful final check. If your application accepts an alias or omitted default, consider redirecting or emitting a canonical URL so search, analytics, and shared links converge on one representation. That decision is not mandatory for every filter screen, but it should be a conscious one.
Key Takeaways
- Dynamic segments identify resources; search parameters describe a request.
- Parse and validate both at the route boundary before data access.
- Put shareable, durable UI state in the URL; keep secrets and local drafts out.
- Preserve unrelated search parameters when constructing a link transition.
- Keep authorization separate from syntactic URL validation.
Next, we will give these route boundaries resilient loading, error, and not-found UI so failures are understandable rather than blank screens.