Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Dynamic Routes, Params, and Search Params in Next.js

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Loading, Error, and Not Found UI in Next.js
Next.js
12m
Oct 15, 2026

Loading, Error, and Not Found UI in Next.js

A route is not complete when its happy path renders. Learn to give loading, unexpected failure, and expected absence distinct, useful UI boundaries in Next.js.

#Next.js#App Router+5
Next.js Layouts, Templates, and Route Groups
Next.js
11m
Oct 1, 2026

Next.js Layouts, Templates, and Route Groups

Layouts, templates, and route groups shape navigation. Learn the persistence, reset, and URL semantics that make route structure intentional.

#Next.js#App Router+5
Server and Client Components in Next.js
Next.js
11m
Sep 24, 2026

Server and Client Components in Next.js

Server and Client Components are a design boundary, not a contest between two rendering styles. Learn what crosses the boundary, how imports affect bundles, and how to keep interactive islands focused.

#Next.js#Server Components+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Two Kinds of URL State
  • Validate at the Route Boundary
  • Keep Search Params Intentional
  • Build Links as State Transitions
  • Key Takeaways
Next.js

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

October 8, 2026•7 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred route diagram connecting a dynamic path segment and search parameter state to a rendered application view
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.