Docs
Next.js Typed Routes

Next.js Typed Routes

Utils to complement Next.js' dynamic routes with TypeScript

Installation

Add experimental typed routes to next.config.js.

/** @type {import("next").NextConfig} */
const config = {
  experimental: {
    typedRoutes: true,
  },
};
 
export default config;

Create lib/navigation/utils.ts

import type { Route } from "next";
import type { LinkProps } from "next/link";
import { redirect as nextRedirect } from "next/navigation";
 
import { cn } from "@/lib/utils";
 
export const linkClassName = (className?: string) =>
  cn(
    "mt-2 block text-xs text-muted-foreground underline hover:text-foreground",
    className,
  );
 
/* -------------------------------------------------------------------------- */
/*                                    ROUTE                                   */
/* -------------------------------------------------------------------------- */
 
type InferRoutes<T> = T extends {
  href: infer H;
}
  ? H
  : never;
 
export type NextRoute<TRoute> = InferRoutes<LinkProps<TRoute>>;
 
/**
 * Alias for the default `next/navigation` redirect function, but with a type-safe `href` parameter.
 */
export function redirect<TRoute>(href: NextRoute<TRoute>): never {
  return nextRedirect(href as string);
}
 
/**
 * Helper to assign a route to a variable in a type-safe way.
 */
export const route = <RouteType>(
  href: NextRoute<RouteType>,
  searchParams?: Record<string, string>,
) => {
  let result = href as string;
  if (searchParams) {
    const search = new URLSearchParams(searchParams).toString();
    result = `${result}?${search}`;
  }
  return result as Route;
};
 
/**
 * Same as "route" but returns a string instead of a Next.js route object.
 */
export const routeAsString = <RouteType>(
  href: NextRoute<RouteType>,
  searchParams?: Record<string, string>,
) => {
  return route(href, searchParams) as string;
};
 
/**
 * Checks if a given string matches a next route. The special thing is
 * that you can check against dynamic routes.
 *
 * @example
 * const route = "/users/3/edit";
 * const matches = matchesRoute(route, "/users/[id]/edit");
 * console.log(matches); // true
 */
export function matchesRoute<RouteType>(
  route: string,
  toMatch: NextRoute<RouteType>,
) {
  const pattern = new RegExp(
    `^${(toMatch as string)
      .replace(/\[\.\.\.\w+\]/g, ".+?")
      .replace(/\[[^\]]+\]/g, "[^/]+")}$`,
  );
  return pattern.test(route);
}
 
/* -------------------------------------------------------------------------- */
/*                                     URL                                    */
/* -------------------------------------------------------------------------- */
 
/**
 * Parses the search parameters from a URL and returns them as an object.
 * @param url - The URL to parse.
 * @returns An object containing the search parameters.
 */
export function getSearchParamsFromUrl(
  url: string,
): Record<string, string | string[] | undefined> {
  return Object.fromEntries(new URL(url).searchParams);
}
 
/**
 * Decodes a URL parameter value by decoding it and converting it to lowercase.
 * @param value - The URL parameter value to decode.
 * @returns The decoded and lowercase value.
 */
export function decodeParam(value: string) {
  return decodeURIComponent(value).toLowerCase();
}
 
/**
 * Encodes a string value for use as a parameter in a URL.
 *
 * @param value - The string value to be encoded.
 * @returns The encoded string value.
 */
export function encodeParam(value: string) {
  return encodeURIComponent(value);
}
 
/**
 * Creates a query string from the given parameters.
 *
 * @param params - The parameters to be included in the query string.
 * @returns The generated query string.
 */
export function createQueryParams(
  params: Record<string, string | boolean | number>,
): string {
  return Object.entries(params)
    .map(([key, value]) => `${key}=${encodeURIComponent(value.toString())}`)
    .join("&");
}

Optional

Parsers

lib/navigation/parser.ts

These functions and hooks can be used to validate the search and dynamic params of the URL, client side and server side.

import { useParams, useSearchParams } from "next/navigation";
import type { z } from "zod";
 
import type { NextRoute } from "@/lib/navigation/utils";
import { redirect } from "@/lib/navigation/utils";
 
interface ParseOptions<TSchema extends z.ZodType> {
  schema: TSchema;
}
 
/* -------------------------------------------------------------------------- */
/*                                   Server                                   */
/* -------------------------------------------------------------------------- */
 
/**
 * Parses the search params of the current route (server-side).
 */
export const parseSearchParams = <
  TSchema extends z.ZodType,
  TRoute,
  TRedirectTo extends NextRoute<TRoute> | undefined,
>(
  unparsedSearchParams: unknown,
  options: ParseOptions<TSchema> & {
    redirectTo?: TRedirectTo;
  },
): TRedirectTo extends string
  ? TSchema["_input"]
  : TSchema["_input"] | null => {
  const params = options.schema.safeParse(unparsedSearchParams);
 
  if (!params.success) {
    if (options.redirectTo) {
      redirect(options.redirectTo as NextRoute<TRoute>);
    }
 
    return null;
  }
 
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
  return params.data as TSchema["_input"];
};
 
/* -------------------------------------------------------------------------- */
/*                                Client Hooks                                */
/* -------------------------------------------------------------------------- */
 
/**
 * Parses the dynamic params of the current route (client-side).
 */
export function useParseParams<TSchema extends z.ZodType>(schema: TSchema) {
  const unparsedParams = useParams();
 
  if (!unparsedParams) {
    throw new Error("useParseParams: No params.");
  }
 
  const params = schema.safeParse(unparsedParams);
 
  if (!params.success) {
    throw new Error("useParseSearchParams: Invalid search params.");
  }
 
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
  return params.data as TSchema["_output"];
}
 
/**
 * Parses the search params of the URL (client-side).
 */
export function useParseSearchParams<TSchema extends z.ZodType>(
  schema: TSchema,
) {
  const searchParamsObject = useSearchParams();
 
  if (!searchParamsObject) {
    throw new Error("useParseSearchParams: No search params.");
  }
 
  const unparsedSearchParams: Record<string, string> = {};
  for (const [key, value] of searchParamsObject.entries()) {
    unparsedSearchParams[key] = value;
  }
 
  const searchParams = schema.safeParse(unparsedSearchParams);
 
  if (!searchParams.success) {
    throw new Error("useParseSearchParams: Invalid search params.");
  }
 
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
  return searchParams.data as TSchema["_output"];
}