JSPM

  • Created
  • Published
  • Downloads 1866
  • Score
    100M100P100Q114150F
  • License MIT

JSON serializable, fully typesafe, and zod validated URL search params, dynamic route params, and routing for NextJS pages directory

Package Exports

  • next-typesafe-url
  • next-typesafe-url/dist/index.js
  • next-typesafe-url/dist/index.mjs

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (next-typesafe-url) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

next-typesafe-url

JSON serializable, fully typesafe, and zod validated URL search params, dynamic route params, and routing for NextJS pages directory.

Big shoutout to tanstack/router and yesmeck/remix-routes for inspiration and ideas.

Whats wrong with curent solutions?

Routing

Next.js's non-typesafe routing can lead to runtime errors and make it difficult to catch routing-related issues during development, as it relies on string literals instead of type-safe constructs.

Search Params

from tanstack/router:

Traditional Search Param APIs usually assume a few things:

  • Search params are always strings
  • They are mostly flat
  • Serializing and deserializing using URLSearchParams is good enough (Spoiler alert: it's not, it sucks)

Typesafety Isn’t Optional

How does next-typesafe-url solve these problems?

  • Fully typesafe routing- all the way from the route, to the route params, to the search params
  • Search params are JSON-first, so you can pass strings, numbers, booleans, nulls, and even nested objects and arrays
  • Search and route params are validated at runtime using zod, so you can be sure that the data you get matches the schema you expect

Installation

npm install next-typesafe-url
# or
yarn add next-typesafe-url
# or
pnpm add next-typesafe-url

Setup

IMPORTANT NOTE

This package requires you to run your npm scripts from the root directory of your project, as well as your pages directory being in root/src.


If you run into any issues it will most likely be the cli (for me it works fine on my setup, but when using it in a github codespace the watch functionality wouldn't work).

Assuming you have the correct directory structure as listed above, a quick npx next-typesafe-url should generate the types and you'll be all set.

If the functions still show type errors, you can restart typescript server, but I have found a quick crtl+click to go the origin type file can often wake the ts server up much faster.

Worst case senario, you may have to restart your editor. If you are still having issues, please open an issue.


Add next-typesafe-url to your dev and build script in package.json.

For dev mode, you can either run it in a seperate shell, or in one with the concurrently package.

{
  "scripts": {
    "build": "next-typesafe-url && next build",

    "dev": "concurrently  \"next-typesafe-url -w\" \"next dev\"",
    // OR
    "dev:url": "next-typesafe-url -w"
  }
}

Usage

Route

next-typesafe-url is powered by exporting a special RouteType type from each route in your pages directory. It is derived from a special Route object, that defines the valid route params and search params for that route.

Note: Route should exclusively include the keys of either routeParams or searchParams, or both, and they must be Zod objects without exception.


If a route doesn't need any route params or search params, you dont need to define a Route object, or export a RouteType type

Any page that does not export a RouteType type will be classified as a StaticRoute, and will throw a type error if you try to link to it with any dynamic route params or search params.


// pages/product/[productID].tsx

const Route = {
  routeParams: z.object({
    productID: z.number(),
  }),
  searchParams: z.object({
    location: z.enum(["us", "eu"]).optional(),
    userInfo: z.object({
      name: z.string(),
      age: z.number(),
    }),
  }),
};
export type RouteType = typeof Route;

Search params support all valid JSON types. However, route params are not JSON serializable, so you can only pass strings, numbers, and/or booleans. You may also pass an array (of strings, numbers and/or booleans) for catch all routes as shown below.


Note: Catch all and optional catch all routes are interepted as arrays or tuples.

// pages/dashboard/[...options].tsx
const Route = {
  routeParams: z.object({
    options: z.tuple([z.string(), z.number()]),
  }),
};
export type RouteType = typeof Route;

// /dashboard/deployments/2 will match and return { options: ["deployments", 2] }

Keep in mind that next-typesafe-url assumes your Route and export RouteType is correct. If you for example, have a route param that is a different name than the file name, it will cause silent errors.

Double check your Route objects to make sure they are correct.

AppRouter Type

next-typesafe-url exports a AppRouter type that you can use to get the type of the valid search params and route params for any given route in your app.

import { type AppRouter } from "next-typesafe-url";

type ProductIDRouteParams = AppRouter["/product/[productID]"]["routeParams"];
// type ProductIDRouteParams = {
//     productID: number;
// }

Path

next-typesafe-url exports a $path function that generates a path that can be passed to next/link or next/router.

import { $path } from "next-typesafe-url";

<Link
  href={$path({
    route: "/product/[productID]",
    routeParams: { productID: 23 },
    searchParams: { userInfo: { name: "bob", age: 23 } },
  })}
/>;

// this generates the following string:
// "/product/23?userInfo=%7B%22name%22%3A%22bob%22%2C%22age%22%3A23%7D"

If the route is not a valid route, or any of the route params or search params are missing or of the wrong type, it will throw a type error.


Note: Strings are passed to the url without quotes, so to provide support for booleans, nulls and numbers there are reserved keywords that are converted to their respective values.

Those keywords are:

  • "true" -> true
  • "false" -> false
  • "null" -> null
  • "<some_numberic_literal>" -> number

"undefined" is not a special keyword, and will be interpreted as a string if passed as the value of a search param in the URL.

In $path, passing undefined as the value of a search param will cause it to be left out of the path.

$path({ route: "/" searchParams: { foo: undefined, bar: true } }) // => "/?bar=true"

// ------------------------

// URL: /?bar=undefined
// value of bar will be the string "undefined", not undefined

Hooks

next-typesafe-url exports a useRouteParams and useSearchParams hook that will return the route params / search params for the current route. They take one argument, the zod schema for either route params or search params from the Route object.

const params = useSearchParams(Route.searchParams);
const { data, isValid, isReady, isError, error } = params;

// if isReady is true, and isError is false (isValid is true), then data *will* be in the shape of Route.searchParams
// in this case, data will be { userInfo: { name: string, age: number } }

if (!isReady) {
  return <div>loading...</div>;
} else if (isError) {
  return <div>Invalid search params {error.message}</div>;
} else if (isValid) {
  return <div>{data.userInfo.name}</div>;
}

isReady is the internal state of next/router, and isError is a boolean that is true if the params do not match the schema. If isError is true, then error will be a zod error object you can use to get more information about the error. (also check out zod-validation-error to get a nice error message)

If isReady is true and isError is false, then data will always be valid and match the schema.

For convenience, instead of needing checking isReady && !isError, I have added the isValid flag which is only true when isReady is true and and isError is false.

Reccomended Usage

It is HIGHLY reccomended to only call useSearchParams and useRouteParams in the top level component of each route, and pass the data down to child components through props or context.

Feel free to use your state management library of choice to pass the data down to child components.

Static/SeverSide Props

next-typesafe-url provides full support for validating route params and search params in getStaticProps and getServerSideProps.

The parseServerSideRouteParams and parseServerSideSearchParams functions are used to parse the route params and search params. They take the same schema from your Route object as the useRouteParams and useSearchParams hooks, as well as the context object from getStaticProps / getServerSideProps.

Errors

Like the hooks, parseServerSideRouteParams and parseServerSideSearchParams have an isError flag, and if it is true, then error will be a zod error object you can use to get more information about the error. Otherwise, data will be the parsed route params or search params.


This is an example of how to use next-typesafe-url with getServerSideProps, but the same pattern can be used with getStaticProps.

In this example I simply pass all of the params as props, but you can use the fully typed and validated data you get back from parseServerSideRouteParams and parseServerSideSearchParams however you wish.

Also note use of the AppRouter type to gather the type of the params

// pages/product/[productID].tsx

import type {
  InferGetServerSidePropsType,
  NextPage,
  GetServerSideProps,
} from "next";
import { z } from "zod";
import {
  $path,
  parseServerSideRouteParams,
  parseServerSideSearchParams,
  type AppRouter,
} from "next-typesafe-url";

const Route = {
  routeParams: z.object({
    productID: z.number(),
  }),
  searchParams: z.object({
    location: z.enum(["us", "eu"]).optional(),
    userInfo: z.object({
      name: z.string(),
      age: z.number(),
    }),
  }),
};
export type RouteType = typeof Route;

type ServerSideProps = AppRouter['/product/[productID]']['searchParams'] &
    AppRouter['/product/[productID]']['routeParams'];

export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (
  context
) => {
  const routeParams = parseServerSideRouteParams({
    context,
    validator: Route.routeParams,
  });

  const searchParams = parseServerSideSearchParams({
    context,
    validator: Route.searchParams,
  });

  if (routeParams.isError || searchParams.isError) {
    console.log(routeParams.error?.message, searchParams.error?.message);
    throw new Error("Invalid route or search params");
  } else {
    return {
      props: {
        ...routeParams.data,
        ...searchParams.data,
      },
    };
  }
};

type PageProps = InferGetServerSidePropsType<typeof getServerSideProps>;

const Page: NextPage<PageProps> = (props) => {
  return (
    <>
      <div>slug: {props.slug}</div>
      <div>search: {`${props.userInfo.name} - ${props.userInfo.age}`}</div>
      <div>location: {props.location}</div>
    </>
  );
};
export default Page;

Command Line Options

  • -w: Watch for changes and automatically rebuild.

License

MIT

TODO