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 an opt-in experimental feature for statically typed links. To enable this, turn on experimental.typedRoutes in your next.config.js like so:
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 hrefprop on the <Link /> component.
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
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 library.
We want a route definition interface that looks like this:
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:
1import Link from 'next/link' 2import {Routes} from '../../routes.ts' 3 4<Link href={Routes.home({orgId: 'g4eion3e3'})} />
That same interface will work for static routes:
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:
1import Link from 'next/link' 2import {Routes} from '../../routes.ts' 3 4<Link href={Routes.about()} />
And that can be extended for query parameters like so:
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:
1import Link from 'next/link' 2import {Routes} from '../../routes.ts' 3 4<Link href={Routes.signup({}, {search: {invitationId: '8haf3dx'}})} />
You can read the route parameters from the Routes object too. Fully type-safe and runtime-validated.
1import {Routes} from '../../routes.ts' 2 3// type = {orgId: string} 4const params = Routes.home.useParams()
You can even read the query parameters from Routes. Fully type-safe and runtime-validated.
1import {Routes} from '../../routes.ts' 2 3// type = {invitationId: string} 4const searchParams = Routes.signup.useSearchParams()
Routes also provides the page prop types:
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}
And Routes let's you validate the page props
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}
makeRoute() utilityHere’s the only utility code you need to accomplish the above.
1npm install zod query-string
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}
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:
1export const LogsSearchParams = z.object({ 2 logsId: z.coerce.number() 3 fullscreen: z.coerce.boolean().default(false), 4})
Ravion enhances and unifies IaC, deployments, CI/CD, and observability into a single interface for context and control. Your team can move faster with better reliability and less devops overhead.
Free to start / No credit card / Revoke access anytime