JSPM

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

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

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 path, to the route params, to the search params
  • Search params (and technically route params too) are JSON-first, so you can pass 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 are getting matches the schema you expect

Installation

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

Setup


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.


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"
  }
}

IMPORTANT NOTE

The cli is probably the most likely part to slightly break. If you run into issues just run a quick npx next-typesafe-url and it should generate the types. 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.

Usage

Route

next-typesafe-url is powered by exporting a special Route object from each route in your pages directory.

Note: Route should only ever contain the keys of either routeParams or searchParams, or both, and they should only ever be Zod objects.

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

Any page that does not export a Route object 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

export 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(),
    }),
  }),
};

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

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

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

Keep in mind that next-typesafe-url assumes your exported Route 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.

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({
    path: "/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 path 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 converted to a string if passed as the value of a search param. Passing undefined as the value of a search param will cause it to be left out of the path.

$path({ path: "/" 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.


If you are looking for a easier way to do globally this check out Jotai.

import { atom, useSetAtom, useAtomValue } from "jotai";

// In route file in pages directory

const productRouteParamsAtom = atom<z.infer<typeof Route.routeParams>>({
  productID: 1,
});

const Page: NextPage = () => {
  const setRouteParams = useSetAtom(productRouteParamsAtom);

  const params = useRouteParams(Route.routeParams);
  const { data, isError, isReady } = params;

  useEffect(() => {
    if (isReady && !isError) {
      setRouteParams(data);
    }
  }, [route, setRouteParams]);

  return (
    <div>
      <DeeperComponent />
    </div>
  );
};
export default Page;

//anwhere in the tree

function DeeperComponent() {
  const routeParams = useAtomValue(productRouteParamsAtom);
  return <div>{routeParams.productID}</div>;
}

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;
// }

Command Line Options

  • -w: Watch for changes and automatically rebuild.

License

MIT

TODO

  • add tests
  • app directory support