mulungood

Runtime type safety in Next 13 routes with zod (lightweight tRPC?)

Leverage zod for building practical, robust, and type-safe API endpoints in NextJS, with 0 validation code. Also applies to other frameworks like Remix, Nuxt & SvelteKit.

Henrique Doro's photoHenrique Doro

tRPC is gaining popularity as a solution for end-to-end type safety of API endpoints. Although I like its premise, I find it too invasive: for NextJS apps you have to replace multiple route files with a single one and completely change how you work with routes.

tRPC's file structure in NextJS - beyond hijacking your individual route files, it also currently doesn't support Next 13's app directory

However, we can benefit from the fact that tRPC is based off zod and create our own mini-tRPC for building practical, robust type-safe API endpoints in Next. Here's the final API we'll get to:

// Ficticious /api/like-posts endpoint export const POST = routeHandler({ // Pass the schema for the request's body bodySchema: z.object({ likedPostIds: z.array(z.string().uuid()).nonEmpty() }), // And/or of query parameters querySchema: z.object({ returnPosts: z.boolean().default(true) }), // And rest assured the handler will only be called if the data is correct handler: async ({ body, query, request }) => { // All properly typed in Typescript ✨ body.likedPostIds // string[] query.returnPosts // boolean }, })

Above, routeHandler will test the request based on the zod schema(s) you define, and return a 400 (bad request) if it doesn't match it. This way, you can work from body/query with confidence, knowing it'll be exactly what your expect.

And what's best, you can leverage zod's power to be as specific as needed: it's not any string, it's an uuid; the array can't be empty; we'll default returnPosts to true. And that doesn't even scratch the surface - refer to its documentation for what is possible if you aren't sold yet ✨

📌 Props to Matt Pocock and his Zod + Generics is HEAVEN video for sparking the idea for this implementation!

Good to know: the routeHandler function can be used for all HTTP methods supported by Next (docs on route handlers), but bodySchema wouldn't apply for GET and HEAD as those requests can't send a body payload to the server.

Going even further, you can use your routeHandler function to expose further project-specific functionality and helpers, like determine the access-level of a route and setting up a database client:

export const POST = routeHandler({ bodySchema: z.object({ likedPostIds: z.array(z.uuid()).nonEmpty() }), querySchema: z.object({ returnPosts: z.boolean().default(true) }), // Set-up further validation rules access: 'authenticated', handler: async ({ body, query, // And receive common utilities supabase, authSession, }) => { await supabase.from('user_likes').insert( body.likedPostIds.map((post_id) => ({ post_id, user_id: authSession.user.id, })), ) if (query.returnPosts) { const { data: posts } = await supabase .from('posts') .select('*') .in('id', body.likedPostIds) return NextResponse.json({ posts }, 200) } // ... }, })

Implementation

Given it's quite small and most of it is types, I'll just paste it - feel free to reach out if you have questions:

import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' export function routeHandler< BodySchema extends z.ZodType, QuerySchema extends z.ZodType, >(props: { bodySchema?: BodySchema querySchema?: QuerySchema handler: (props: { request: NextRequest body: z.infer<BodySchema> query: z.infer<QuerySchema> }) => Promise<NextResponse> }) { return async (request: NextRequest) => { const [rawBody] = await Promise.allSettled([request.json()]) const body = props.bodySchema?.safeParse( rawBody.status === 'fulfilled' ? rawBody.value : undefined, ) if (body?.success === false) { return NextResponse.json( // @TODO: should we return the full Zod error or can this lead to a security breach? { message: 'Invalid body', errors: body.error.issues }, { status: 400 }, ) } const rawQuery = extractQueryParams(request.url) const query = props.querySchema?.safeParse(rawQuery) if (query?.success === false) { return NextResponse.json( { message: 'Invalid query', error: query.error.message }, { status: 400 }, ) } return await props.handler({ request, body: body?.data, query: query?.data, }) } } // @TODO: how to handle query params for arrays with only one value? function extractQueryParams(url: string) { const params = new URL(url).searchParams const entries = Array.from(params.entries()) return entries.reduce( (queryObj, [key, value]) => { const existingValue = queryObj[key] if (existingValue) return { ...queryObj, [key]: [ ...(Array.isArray(existingValue) ? existingValue : [existingValue]), value, ], } return { ...queryObj, [key]: value } }, {} as { [key: string]: string | string[] }, ) }

You'll notice I have two @TODOs above - these are ongoing questions I haven't fully answered yet.

Ah, and here's how you could, for example, pass a database client to all handlers (in our case, Supabase):

export function routeHandler(props: { // ... handler: (props: { // ... supabase: SupabaseClient<Database> }) => Promise<NextResponse> }) { return async (request: NextRequest) => { const supabase = createRouteHandlerClient<Database>({ cookies }) // ... return await props.handler({ // ... supabase, }) } }

That's it! Our routes are now completely type safe, not just at compile-time with vanilla Typescript, but also at runtime. No broken data, no unknown keys in objects, auto-transforms at a type-level…

People can send as many faulty requests as they want, none will go through and we didn't have to write a single line of per-route validation.

That's the zen of zod and parsing, not validating.

Addendum on tRPC: I'm willing to go over the hump of the initial strangeness it brings. However, with the move to the Edge, I worry this may lead to excessive bundle sizes that can't be deployed to Vercel, Cloudflare and other runtimes, given their limited capacity.