# Fix Next.js routing to have full type-safety

By Brandon Bayer · Jan 16, 2024

Broken links, incorrectly formatted query strings, and missing route parameters are all easily solvable with a type system like Typescript.

Sadly, most modern routing solutions, including Next.js, don’t include this, leaving us sad and alone on a cold, dark night.

## Next.js has limited built-in type safety

Next.js has an opt-in experimental feature for [statically typed links](https://nextjs.org/docs/app/building-your-application/configuring/typescript#statically-typed-links). To enable this, turn on `experimental.typedRoutes` in your `next.config.js` like so:

```typescript
1/** @type {import('next').NextConfig} */
2const nextConfig = {
3  experimental: {
4    typedRoutes: true,
5  },
6}
7
8module.exports = nextConfig
```

Now Next.js will generate link definitions in `.next/types` that will override the default type of the `href`prop on the `<Link />` component.

```typescript
1import Link from 'next/link'
2
3// No TypeScript errors if href is a valid route
4<Link href="/about" />
5
6// TypeScript error
7<Link href="/aboot" />
```

This is a good start, but it has many limitations:

-   no type validation of route or query string parameters

-   no runtime validation of route or query string parameters

-   no autocomplete for dynamic routes — very cumbersome in a large application with many dynamic routes

-   no type validation at all when passing href as an object like `<Link href={{href: '/about', query: {ref: 'hello'}}} />` (but I don’t think this even works anymore in app router)

-   no type or runtime validation for `useParams()` and `useSearchParams`

-   it couples the page reference to the route string — if you restructure your routes, you must also change all the links

## Ideal type-safe routing for Next.js

A fully featured type-safe routing system should support the following:

-   static type validation of routes

-   static type validation of route parameters

-   static type validation of query string parameters

-   runtime validation of route parameters

-   runtime validation of query string parameters

-   decouple route names from the route urls (this makes it easy to restructure urls without having to update links everywhere)

-   type safe `useParams()` and `useSearchParams()`

-   easily re-use route parameter types for Page props

To achieve both type and runtime validation, we’ll use the excellent [Zod](https://github.com/colinhacks/zod) library.

### Dynamic routes

We want a route definition interface that looks like this:

```typescript
1//routes.ts
2import {z} from 'zod'
3
4export const OrgParams = z.object({orgId: z.string()})
5
6export const Routes = {
7  home: makeRoute(({orgId}) => `/org/${orgId}`, OrgParams)
8}
```

And that can be used like this:

```typescript
1import Link from 'next/link'
2import {Routes} from '../../routes.ts'
3
4<Link href={Routes.home({orgId: 'g4eion3e3'})} />
```

### Static routes

That same interface will work for static routes:

```typescript
1//routes.ts
2import {z} from 'zod'
3
4export const Routes = {
5  about: makeRoute(() => `/about`, z.object({}) /* no params */)
6}
```

That can be used like this:

```typescript
1import Link from 'next/link'
2import {Routes} from '../../routes.ts'
3
4<Link href={Routes.about()} />
```

### Query parameters

And that can be extended for query parameters like so:

```typescript
1//routes.ts
2import {z} from 'zod'
3
4export const SignupSearchParams = z.object({
5  invitationId: z.string().optional().nullable(),
6})
7
8export const Routes = {
9    signup: makeRoute(() => "/signup", z.object({}), SignupSearchParams),
10}
```

And that can be used like this:

```typescript
1import Link from 'next/link'
2import {Routes} from '../../routes.ts'
3
4<Link href={Routes.signup({}, {search: {invitationId: '8haf3dx'}})} />
```

### useParams()

You can read the route parameters from the `Routes` object too. Fully type-safe and runtime-validated.

```typescript
1import {Routes} from '../../routes.ts'
2
3// type = {orgId: string}
4const params = Routes.home.useParams()
```

### useSearchParams()

You can even read the query parameters from `Routes`. Fully type-safe and runtime-validated.

```typescript
1import {Routes} from '../../routes.ts'
2
3// type = {invitationId: string}
4const searchParams = Routes.signup.useSearchParams()
```

### Page prop types

`Routes` also provides the page prop types:

```typescript
1import {Routes} from '../../routes.ts'
2
3type HomePageProps = {
4  params: typeof Routes.home.params
5}
6
7export default async function HomePage({
8  params: {organizationId},
9}: HomePageProps) {
10  // render stuff
11}
```

### Page prop runtime validation

And `Routes`  let's you validate the page props

```typescript
1import {Routes} from '../../routes.ts'
2
3type HomePageProps = {
4  params: typeof Routes.home.params
5}
6
7export default async function HomePage({params}: HomePageProps) {
8   const safeParams = Routes.home.parse(params)
9
10  // render stuff
11}
```

## The `makeRoute()` utility

Here’s the only utility code you need to accomplish the above.

```bash
1npm install zod query-string
```

```typescript
1import {z} from 'zod'
2import {useParams as useNextParams, useSearchParams as useNextSearchParams} from "next/navigation"
3import queryString from "query-string"
4
5export const OrgParams = z.object({orgId: z.string()})
6
7export const Routes = {
8  home: makeRoute(({orgId}) => `/org/${orgId}`, OrgParams)
9}
10
11type RouteBuilder<Params extends z.ZodSchema, Search extends z.ZodSchema> = {
12  (p?: z.input<Params>, options?: {search?: z.input<Search>}): string
13  parse: (input: z.input<Params>) => z.output<Params>
14  useParams: () => z.output<Params>
15  useSearchParams: () => z.output<Search>
16  params: z.output<Params>
17}
18
19const empty: z.ZodSchema = z.object({})
20
21function makeRoute<Params extends z.ZodSchema, Search extends z.ZodSchema>(
22  fn: (p: z.input<Params>) => string,
23  paramsSchema: Params = empty as Params,
24  search: Search = empty as Search,
25): RouteBuilder<Params, Search> {
26  const routeBuilder: RouteBuilder<Params, Search> = (params, options) => {
27    const baseUrl = fn(params)
28    const searchString = options?.search && queryString.stringify(options.search)
29    return [baseUrl, searchString ? `?${searchString}` : ""].join("")
30  }
31
32 routeBuilder.parse = function parse(args: z.input<Params>): z.output<Params> {
33  const res = paramsSchema.safeParse(args)
34   if (!res.success) {
35     const routeName =
36       Object.entries(Routes).find(([, route]) => (route as unknown) === routeBuilder)?.[0] ||
37       "(unknown route)"
38     throw new Error(`Invalid route params for route ${routeName}: ${res.error.message}`)
39   }
40   return res.data
41 }
42
43 routeBuilder.useParams = function useParams(): z.output<Params> {
44   const res = paramsSchema.safeParse(useNextParams())
45   if (!res.success) {
46     const routeName =
47       Object.entries(Routes).find(([, route]) => (route as unknown) === routeBuilder)?.[0] ||
48       "(unknown route)"
49     throw new Error(`Invalid route params for route ${routeName}: ${res.error.message}`)
50   }
51   return res.data
52 }
53
54 routeBuilder.useSearchParams = function useSearchParams(): z.output<Search> {
55   const res = search.safeParse(convertURLSearchParamsToObject(useNextSearchParams()))
56   if (!res.success) {
57     const routeName =
58       Object.entries(Routes).find(([, route]) => (route as unknown) === routeBuilder)?.[0] ||
59       "(unknown route)"
60     throw new Error(`Invalid search params for route ${routeName}: ${res.error.message}`)
61   }
62   return res.data
63 }
64
65
66  // set the type
67  routeBuilder.params = undefined as z.output<Params>
68  // set the runtime getter
69  Object.defineProperty(routeBuilder, "params", {
70    get() {
71      throw new Error(
72        "Routes.[route].params is only for type usage, not runtime. Use it like `typeof Routes.[routes].params`",
73      )
74    },
75  })
76
77  return routeBuilder
78}
79
80export function convertURLSearchParamsToObject(
81  params: ReadonlyURLSearchParams | null,
82): Record<string, string | string[]> {
83  if (!params) {
84    return {}
85  }
86
87  const obj: Record<string, string | string[]> = {}
88  for (const [key, value] of params.entries()) {
89    if (params.getAll(key).length > 1) {
90      obj[key] = params.getAll(key)
91    } else {
92      obj[key] = value
93    }
94  }
95  return obj
96}
```

### Bonus for number and boolean query strings

Search query parameters are always typed as strings in the browser, so for numbers and booleans you’ll need to use zod's `coerce` feature like this:

```typescript
1export const LogsSearchParams = z.object({
2  logsId: z.coerce.number()
3  fullscreen: z.coerce.boolean().default(false),
4})
```

## The agent-native cloud infrastructure platform for production-grade AWS

Give developers and AI agents one governed way to provision, deploy, and operate applications in your AWS account. Move fast without building every DevOps capability yourself.

[Start free](https://app.ravion.com/signup)

[Book a demo](https://www.ravion.com/demo)

Free to start / No credit card / Revoke access anytime
