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.
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 @TODO
s 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.