End-to-End Typesafety with Next.js, tRPC, and Zod

If you’d rather, take a look at the full GitHub repo or you can give the application a test run on CodeSandbox


Unlike traditional Javascript applications, frameworks like Next.js and Remix allow you to put your server and client code in a single application. This provides a lot of benefits like colocation, global Types, and the ability to access all parts of your application at once. Although we can create API routes which we can access from the client, we still need a way to ensure Typesafety when we send data to and from these endpoints.

Bridging the Client/Server Boundary

Short for “TypeScript Remote Procedure Call”, tRPC allows our application to “call” server-side functions from the client. The benefit this gives us is that we can share Types from our server server functions with the client. Combined with Zod, a powerful data validation library, we can build a robust application that ensures Typesafe from one end of our application to the other.

Project Setup

In this demo project, we’ll be using the default Next.js 14 installation, as well as a few other dependencies that allow us to build quickly.

We’re going to create a simple application to send, receive, and view some contrived “blog post” data. Let’s dive in!

File Structure

This is a hybrid approach of how Next.js and tRPC recommend you set up your application. I prefer to organize my “utility” functions within their respective libraries’ folders in ./utils/. Nonetheless, this what your application will look like when we’re all done.

- app/ # <-- this is where the Next.js application lives
- api/trpc/[trpc]/route.ts # <-- this is the dynamic tRPC HTTP handler
- layout.tsx # <-- the topmost layer of our Next.js app. We add the providers here
- page.tsx # <-- the homepage
- provider.tsx # <-- where our providers are defined
- prisma/ # <-- if prisma is added
- schema.prisma # <-- this defines the database schema
- server/
- routers/
- posts.ts # <-- sub routers
- [...].ts
- index.ts # <-- main trpc "router"
- utils/
- prisma/
- index.ts # <-- this is the global prisma object is initialized
- react-query/
- post.ts # <-- any custom react-query hooks, these are optional, but useful
- trpc/
- client.ts # <-- where the client side trpc "factory" is defined
- index.ts # <-- the global trpc object is initialized
- posts.ts # <-- where the server side trpc "factory" is defined
- zod/
- schema.ts # <-- where the zod schemas are defined

Install Dependencies

  1. Start by running npx create-next-app@14 and use the following options:

    Terminal window
    Would you like to use TypeScript? Yes
    Would you like to use ESLint? Yes
    Would you like to use Tailwind CSS? No
    Would you like to use `src/` directory? No
    Would you like to use App Router? Yes
    Would you like to customize the default import alias? No

    You can install Tailwind CSS if you want, but its not necessary for this demo. Also, feel free to remove all the default Next.js boilerplate, we’re not going to need it.

  2. Next, let’s install the some of other tools we’re going to use. I’ll explain each below:

    Terminal window
    npm install @trpc/server@next @trpc/client@next @trpc/react-query@next @tanstack/react-query @tanstack/react-query-devtools prisma @prisma/client zod
    • @trpc/server, @trpc/client, @trpc/react-query

    This let’s us run tRPC on the client and the server, as well as integrate with an awesome library called TanStack Query.

    (As of writing @trpc/...@next will install v11 which is compatible with TanStack Query v5. If you’re using tRPC v10, use TanStack Query v4)

    • @tanstack/react-query, @tanstack/react-query-devtools

    TanStack Query is a data management library. It handles nearly everything we need when it comes to managing our data. And while its not technically necessary to use with tRPC, they work together extremely well. Frankly, I wouldn’t build an application without using TanStack Query. It also comes with a really handy devtool, which I highly recommend.

    • prisma, @prisma/client

    Prisma is our Typescript ORM. Again, its not technically necessary to use with tRPC, but in this demo we’re building a full-stack app and this is how we’ll interact with our database. You could also use another popular tool, Drizzle.

    For this demo, we’ll create a simple blog post schema using Prisma. And to keep things simple, we’ll be using an SQLite database.

    Create the schema file below:

    ./prisma/schema.prisma
    generator client {
    provider = "prisma-client-js"
    }
    datasource db {
    provider = "sqlite"
    url = "file:../data/dev.db"
    }

    Notice we’re putting the database inside root data/ directory. You can put it wherever you like, but since we’re putting this demo on CodeSandbox, that’s where they recommend putting SQLite databases.

    Then instantiate Prisma like so:

    "./utils/prisma/index.ts
    import { PrismaClient } from '@prisma/client'
    const prismaGlobal = global as typeof global & {
    prisma?: PrismaClient
    }
    export const prisma: PrismaClient =
    prismaGlobal.prisma ||
    new PrismaClient({
    log:
    process.env.NODE_ENV === 'development'
    ? ['query', 'error', 'warn']
    : ['error'],
    })
    if (process.env.NODE_ENV !== 'production') {
    prismaGlobal.prisma = prisma
    }

    This file might seem a bit weird, but we’re just ensuring that during development we’re re-using a single database connection rather than creating a new connection every time our dev server restarts. We’re also enabling various levels of logging.

    • zod

    Zod is our schema validation tool. It provides some really great benefits to our application like data validation and Type inferences. Another great library you could use for this, which is inspired by Zod (but with a bit of a smaller footprint), is Valibot.

Setup tRPC and TanStack Query

Both of these libraries need access to our entire application, both on the server and the client.

Let’s start by setting up our tRPC files

  1. Initialize tRPC. This will allow us to build and access our global tRPC functions throughout our application.

    ./utils/trpc/index.ts
    import { initTRPC } from '@trpc/server'
    const t = initTRPC.create({})
    export const router = t.router
    export const publicProcedure = t.procedure
    export const createCallerFactory = t.createCallerFactory
  2. Then, create your tRPC “server”. Just like setting up a Node.js server, you can build routes on the top-level object or create deeper routes by nesting your functions within your router object. Notice the Types we’re able to create at the bottom, you’ll see these come up again later.

    ./server/index.ts
    import {
    type inferRouterInputs,
    type inferRouterOutputs,
    } from '@trpc/server'
    import { router } from '@/utils/trpc'
    export const appRouter = router({
    // myProcedure: {...}
    })
    // export type definition of API
    export type AppRouter = typeof appRouter
    export type RouterOutputs = inferRouterOutputs<AppRouter>
    export type RouterInputs = inferRouterInputs<AppRouter>
  3. Next, we’ll create our client and server-side tRPC “factories”. On the client, we’ll be using the tRPC object. On the server, like when server-side rendering, or on custom non-tRPC routes, we’ll use caller.

    ./utils/trpc/client.ts
    import { type AppRouter } from '@/server'
    import { createTRPCReact } from '@trpc/react-query'
    export const trpc = createTRPCReact<AppRouter>({})

    ./utils/trpc/server.ts
    import { appRouter } from '@/server'
    import { createCallerFactory } from '@/utils/trpc'
    const createCaller = createCallerFactory(appRouter)
    export const caller = createCaller({})
  4. Then, we’re going to set up our dynamic tRPC API route. This is how we’ll interact with each of the routes we set up before.

    ./app/api/trpc/[trpc]/route.ts
    import { appRouter } from '@/server'
    import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
    const handler = (req: Request) => {
    return fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
    })
    }
    export { handler as GET, handler as POST }
  5. Finally, we’ll wrap our application with our tRPC and TanStack Query providers. Now we’ll be able to access all of our data from anywhere in our application. This is also where we’re going to add the TanStack Query debugger.

    ./app/provider.tsx
    'use client'
    import * as React from 'react'
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
    import { httpBatchLink, loggerLink } from '@trpc/client'
    import { trpc } from '@/utils/trpc/client'
    export function getBaseUrl() {
    if (typeof window !== 'undefined')
    // browser should use relative path
    return ''
    if (process.env.DEPLOYMENT_URL)
    // reference for your deployment URL
    return `https://${process.env.DEPLOYMENT_URL}`
    // local development URL
    return `http://localhost:${process.env.PORT ?? 3000}`
    }
    export function TrpcProvider({ children }: { children: React.ReactNode }) {
    const [queryClient] = React.useState(() => new QueryClient())
    const [trpcClient] = React.useState(() =>
    trpc.createClient({
    links: [
    loggerLink({
    enabled: () => true,
    }),
    httpBatchLink({
    url: `${getBaseUrl()}/api/trpc`,
    }),
    ],
    }),
    )
    return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
    <QueryClientProvider client={queryClient}>
    {children}
    <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
    </trpc.Provider>
    )
    }

    We’ll wrap our application with that provider:

    ./app/layout.tsx
    import { TrpcProvider } from './provider'
    export default function RootLayout({
    children,
    }: {
    children: React.ReactNode
    }) {
    return (
    <html lang="en">
    <body>
    <TrpcProvider>{children}</TrpcProvider>
    </body>
    </html>
    )
    }

Great, we’ve done everything we need to start using these tools. Next, we’ll start adding some functionality to our app.

Create Our First Procedure

Before we create our first tRPC router, let’s first create our Prisma and Zod schemas.

In our Prisma Schema file, we’ll create a schema for our Post model. This will let us create a database migration that will set up our database with the correct columns and generate the related Types.

./prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../data/dev.db"
}
model Post {
id Int @id @default(autoincrement())
title String?
content String?
order Int?
isFeatured Boolean?
status String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Then, to set up our database and generate Types from our Prisma schema run the following:

Terminal window
npx prisma migrate dev --name addPostSchema

This is going to create your first database migration and generate the Types used by Prisma. It will also create your SQLite database, if it doesn’t exist already.

Next, we’ll create a similar schema with Zod. This is what we’re going to be reusing around our application to validate our “Post” data.

./utils/zod/schema.ts
import { z } from 'zod'
export const PostOptions = ['Draft', 'Published', 'Review'] as const
export const PostSchema = z.object({
id: z.coerce.number(),
title: z.string().nullable(),
content: z.string().nullable(),
order: z.coerce.number().nullable(),
isFeatured: z.coerce.boolean(),
status: z.enum(PostOptions),
createdAt: z.date(),
updatedAt: z.date(),
})

Notice that in the Zod schema, status is an enum. That means that only one of the three values defined will be valid. SQLite doesn’t have the concept of enums, so our database will be satisfied with any string it receives. Therefore, the Type of status from our database is String but our Type of status from Zod is a Union. Fortunately, those types are not incompatible, but we’ll want to resolve that so our Types are accurate. I’ll show how we can resolve that next.

Let’s go back to our tRPC router, where we’ll build our PostRouter procedures. These are the functions we’re going to use to send and receive data from the client.

./server/routers/posts.ts
import { z } from 'zod'
import { publicProcedure, router } from '@/utils/trpc'
import { PostSchema } from '@/utils/zod/schema'
import { prisma } from '@/utils/prisma'
export const postRouter = router({
getAll: publicProcedure.query(async () => {
const data = await prisma.post.findMany()
return data.map((d) => PostSchema.parse(d))
}),
create: publicProcedure
.input(
z.object({
payload: PostSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
}),
}),
)
.mutation(async ({ input }) => {
const { payload } = input
const data = await prisma.post.create({
data: payload,
})
return PostSchema.parse(data)
}),
})

Then import that into your base “server” router:

./server/index.ts
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'
import { router } from '@/utils/trpc'
import { postRouter } from './routers/posts'
export const appRouter = router({
// myProcedure: {...}
post: postRouter, // creates an API route like api/trpc/post.*
})
// export type definition of API
export type AppRouter = typeof appRouter
export type RouterOutputs = inferRouterOutputs<AppRouter>
export type RouterInputs = inferRouterInputs<AppRouter>

There are a few things happening in the file above, let’s walk through it.

  • getAll and create are the procedures we can call from the trpc object. We’ll access them by using trpc.post.getAll.useQuery, for example.

  • publicProcedure is our base tRPC procedure that handles all the tRPC querying and mutations.

  • In our create procedure, we’re using a publicProcedure.input. input is where we validate the data that’s being passed to the procedure. In this case, we’re expecting an object with a key of payload that contains our Post object, but without the id, createdAt, and updatedAt keys. We don’t want that information to be sent from the client, because this is information that our database is going to create for us.

  • mutation is the step where our data manipulation occurs. We take the validated data from our input object and we store it in our database using Prisma.

  • Lastly, remember how our Prisma and Zod status Types differed? Our PostSchema ensures that status has the correct value when it reaches the server input step. We also use PostSchema.parse when the data is returned to the client which ensures we have the correct values coming from the database.

Our Procedures in Action

Finally, we’re going to start interacting with our data. Start by creating two components, one will be a form to send our data and the other will render all the posts.

./app/_components/post-data.tsx
'use client'
import { trpc } from '@/utils/trpc/client'
export default function PostData() {
const { data: posts } = trpc.post.getAll.useQuery()
return (
<pre>
<div>{JSON.stringify(posts, null, 2)}</div>
</pre>
)
}
./app/_components/form.tsx
'use client'
import { trpc } from '@/utils/trpc/client'
import { PostOptions } from '@/utils/zod/schema'
import * as React from 'react'
type Option = (typeof PostOptions)[number]
export default function Form() {
const [title, setTitle] = React.useState('')
const [content, setContent] = React.useState('')
const [order, setOrder] = React.useState('')
const [isFeatured, setIsFeatured] = React.useState('')
const [status, setStatus] = React.useState<Option>(PostOptions[0])
const { post: postUtils } = trpc.useUtils()
const { mutateAsync: handleCreatePost } = trpc.post.create.useMutation({
// Refetch data once mutation resolves
onSettled: () => {
postUtils.getAll.invalidate()
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleCreatePost({
payload: {
isFeatured: isFeatured === 'on',
order: Number(order),
title,
content,
status,
},
})
}
return (
<form onSubmit={(e) => handleSubmit(e)} className="space-y-2">
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
onChange={(e) => setContent(e.target.value)}
/>
</div>
<div>
<label htmlFor="order">Post Order</label>
<input
id="order"
name="order"
onChange={(e) => setOrder(e.target.value)}
type="number"
/>
</div>
<div>
<label htmlFor="isFeatured">Featured?</label>
<input
id="isFeatured"
name="isFeatured"
onChange={(e) => setIsFeatured(e.target.value)}
type="checkbox"
/>
</div>
<div>
<label htmlFor="status">Status</label>
<select
id="status"
name="status"
onChange={(e) => setStatus(e.target.value as Option)}
required
>
<option value="">-- Choose --</option>
{PostOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<button type="submit" className="rounded bg-black p-2 text-white">
Submit
</button>
</div>
</form>
)
}

We’ll add those components to our home page:

import Form from './_components/form'
import PostData from './_components/post-data'
export default function Home() {
return (
<div style={{ maxWidth: '1280px', marginLeft: '0 auto', display: 'flex' }}>
<div style={{ width: '50%' }}>
<Form />
</div>
<div style={{ width: '50%' }}>
<PostData />
</div>
</div>
)
}

Great! Now you should be able to play around with your application and see how everything comes together. Our global Types are being used and ensure consistency throughout our application, you’ll have autocomplete when writing your trpc functions, and the data will be managed and cached by TanStack Query.

If we were to add more pages, you’d notice that when you navigate back to the home page (via client-side routing), your data will already be there. That’s the benefit of using TanStack Query. It caches your stale data and shows it immediately while simultaneously refetching fresh data in the background.

However, we’re still not completely typesafe. There’s one more piece we’re missing to ensure we’re truly Typesafe.

SuperJSON

You may have already noticed that with our current setup, createdAt and updatedAt values are Typed as String rather than Date even though both Zod and Prisma both Type our dates as Date. This is due to our data being serialized when it gets sent to the client which happens after Zod validates our data.

Technically, we could convert those strings back to dates by doing new Date(post.createdAt). But this can get more complicated as we work with more dates and other data that lose their Types when serialized. And the whole point of this demo is to ensure that are Types are what we expect them to be anywhere! So let’s fix this.

We can fix this by adding SuperJSON as a transformer to tRPC. SuperJSON lets us serialize and deserialize our data in a way that maintains the Types of our data.

First, let’s install SuperJSON:

Terminal window
npm install superjson

Then we’ll add it to tRPC:

./utils/trpc/index.ts
import SuperJSON from 'superjson'
// ...
const t = initTRPC.create({
transformer: SuperJSON,
})
// ...
./provider.tsx
import SuperJSON from 'superjson'
// ...
const [trpcClient] = React.useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: () => true,
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
transformer: SuperJSON,
}),
)
//...

Now if we check the Types of our dates, they’ll correctly show as Date on both the server and the client. No matter where we go in our application, the Types of our data will be accurate. Awesome!

This demo is can be a lot to look at all at once. But it creates an incredible foundation for building out a full application. As we add more data and functionality, we can truly reap the value of our data validation and Typescript. This will allow us to move much faster, prevent silly and unnecessary bugs, and provide further enhancements to our application.

Although that’s the end of the end-to-end Typesafety demonstration, we can still squeeze out additional value from some of the tools we’re using. So if you’re interested, keep reading!

Bonus: Initial Data, Optimistic Updates, and Form Validation

If you’re still with me, we’re going to keep building our application to do a few more cool things that will really make our app feel great.

Initial Data

If you reload your window, you’ll notice that it takes a moment for our data to be visible on the page. This is because our PostData component needs to load before it can fetch the data. This is similar to how an SPA might work. But Next.js gives us SSR functionality, so let’s leverage that using tRPC and TanStack Query so our data is available immediately the page loads.

On our home page, we’ll use caller instead of trpc because that’s how we can interact with tRPC procedures from the server. Then we’ll pass that data as initialData which will prime our cache, just like as if we had called our procedure from the client.

./app/page.tsx
import { caller } from '@/utils/trpc/server'
import Form from './_components/form'
import PostData from './_components/post-data'
export default function Home() {
export default async function Home() {
const posts = await caller.post.getAll()
return (
<div style={{ maxWidth: '1280px', marginLeft: '0 auto' }}>
<div style={{ width: '50%' }}>
<Form />
</div>
<div style={{ width: '50%' }}>
<PostData />
<PostData initialData={posts} />
</div>
</div>
)
}

We’ll also tell TanStack Query not to refetchOnMount if initialData is provided. retchOnMount triggers the initial fetch when our component loads. Since we’re providing that data upfront, we don’t need to immediately fetch that data again.

./_components/post-data.tsx
'use client'
import { type RouterOutputs } from '@/server'
import { trpc } from '@/utils/trpc/client'
export default function PostData() {
export default function PostData({
initialData,
}: {
initialData?: RouterOutputs['post']['getAll']
}) {
const { data: posts } = trpc.post.getAll.useQuery()
const { data: posts } = trpc.post.getAll.useQuery(undefined, {
initialData,
refetchOnMount: initialData ? false : true
})
return (
<pre>
<div>{JSON.stringify(posts, null, 2)}</div>
</pre>
)
}

Remember when we created our RouterOutputs Type before? Now we can use it to Type the initialData prop on PostData.

Now when we reload the page, our data is there immediately! Our data is loaded server-side but can still be cached and manipulated with our client-side cache; and its Typed correctly!

Optimistic Updates

In our current setup, when we create a new post, it gets added to our cache once TanStack Query refetches all of our data after the POST (to create our post) and GET (to fetch our fresh list of posts) requests resolve. In a typical environment, this could take several hundred milliseconds, maybe more.

(Admittedly, in our app, this is happening very fast because everything we’re doing is local.)

Typically, we know what our data is going to look like when it comes back. And we know that 99% of the time our requests will be successful. Because of this, we can cheat a little and update our cache immediately, as the data is being sent to the server. Rather than having to wait for our requests to resolve.

TanStack Query has a few internal callbacks which let us do this safely like handling situations our request fails.

./app/_components/form.tsx
import { RouterInputs } from '@/server'
// ...
const { post: postUtils } = trpc.useUtils()
const { mutateAsync: handleCreatePost } = trpc.post.create.useMutation({
onMutate: async ({
payload,
}: {
payload: RouterInputs['post']['create']['payload']
}) => {
await postUtils.getAll.cancel()
const previous = postUtils.getAll.getData()
postUtils.getAll.setData(undefined, (old) => [
...(old || []),
{
...payload,
id: -1, // We don't know what the id will be, but we need one to bet set
createdAt: new Date(),
updatedAt: new Date(),
},
])
return { previous, updated: payload }
},
onError: (err, updated, context) => {
postUtils.getAll.setData(undefined, context?.previous)
},
onSettled: () => {
postUtils.getAll.invalidate()
},
})
// ...

Let’s go over what’s happening here:

  1. First, we’re canceling queries that fire at the same time to avoid issues with our optimistic update competing with refetched data.
  2. Then, we store our previous data for safe-keeping. We’ll use this later to roll back our changes if something goes wrong.
  3. After that, we manually update the cache by merging the old data with the new data. Because we’re not sending an id, createdAt, or updatedAt, we’ll need to set those manually here. We return the updated data and the previous data in order to pass it to the next callback.
  4. If something should go wrong for any reason, we use onError to set our cache back to the data it had previously.
  5. Finally, onSettled runs at the very end. By invalidating the cache, we’ll force our query to refetch and get the freshest data on the server. We were already doing this.

Great! Now the moment we submit our form, the data is instantly added to our cache. This makes our application feel lightning-fast whether its local or on production with a poor connection!

Form Validation

Last but not least, form validation. So far, we’ve talked about maintaining our Type integrity as we move data around our application. But Type safety is really just a side-effect of the parsing we’re doing with Zod. Because Zod is really a data parsing library, we can go beyond just the Typescript benefits.

Let’s say we want to prevent Posts from having a blank title. Nothing about our Types prevents this from happening since an empty string is still a String. We could wait until our data gets sent to the server, but that’s a lame user experience since our form will submit successfully, even though our data is bad and because the optimistic updates we created before make it seem like everything worked correctly.

Instead, we can use Zod in combination with React Hook Form. This will allow us to use the parsing power of Zod before our form is even submitted. By doing this we can significantly improve the user experience of our form.

First, let’s update our PostSchema so that title has a minimum length of 5 characters:

./utils/zod/schema.ts
import { z } from 'zod'
export const PostOptions = ['Draft', 'Published', 'Review'] as const
export const PostSchema = z.object({
id: z.coerce.number(),
title: z.string().nullable(),
title: z.string().min(5).nullable(),
content: z.string().nullable(),
order: z.coerce.number().nullable(),
isFeatured: z.coerce.boolean(),
status: z.enum(PostOptions),
createdAt: z.date(),
updatedAt: z.date(),
})

Now if we try submit our form with a title of fewer than 5 characters our server will respond with an error. That ensures the invalid can’t reach the database, but we want to prevent invalid data from being sent at all. Let’s install React Hook Form:

Terminal window
npm install react-hook-form @hookform/resolvers

Now we’ll update our form to use React Hook Form:

./app/_components/form.tsx
'use client'
import { RouterInputs } from '@/server'
import { trpc } from '@/utils/trpc/client'
import { PostOptions, PostSchema } from '@/utils/zod/schema'
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { z } from 'zod'
const FormPayload = PostSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
})
type FormData = z.infer<typeof FormPayload>
export default function Form() {
const { post: postUtils } = trpc.useUtils()
const { mutateAsync: handleCreatePost } = trpc.post.create.useMutation({
onMutate: async ({
payload,
}: {
payload: RouterInputs['post']['create']['payload']
}) => {
await postUtils.getAll.cancel()
const previous = postUtils.getAll.getData()
postUtils.getAll.setData(undefined, (old) => [
...(old || []),
{
...payload,
id: -1, // We don't know what the id will be, but we need one to bet set
createdAt: new Date(),
updatedAt: new Date(),
},
])
return { previous, updated: payload }
},
onError: (err, updated, context) => {
postUtils.getAll.setData(undefined, context?.previous)
},
onSettled: () => {
postUtils.getAll.invalidate()
},
})
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(FormPayload),
progressive: true,
})
const onSubmit: SubmitHandler<FormData> = async (form) => {
await handleCreatePost({
payload: form,
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
aria-invalid={errors.title ? 'true' : 'false'}
aria-errormessage={errors.title ? 'title-error' : undefined}
{...register('title')}
/>
{errors.title ? <p id="title-error">{errors.title?.message}</p> : null}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
aria-invalid={errors.content ? 'true' : 'false'}
aria-errormessage={errors.content ? 'content-error' : undefined}
{...register('content')}
/>
{errors.content ? (
<p id="content-error">{errors.content?.message}</p>
) : null}
</div>
<div>
<label htmlFor="order">Post Order</label>
<input
id="order"
aria-invalid={errors.order ? 'true' : 'false'}
aria-errormessage={errors.order ? 'order-error' : undefined}
{...register('order')}
type="number"
/>
{errors.order ? <p id="order-error">{errors.order?.message}</p> : null}
</div>
<div>
<label htmlFor="isFeatured">Featured?</label>
<input
id="isFeatured"
aria-invalid={errors.isFeatured ? 'true' : 'false'}
aria-errormessage={errors.isFeatured ? 'isFeatured-error' : undefined}
{...register('isFeatured')}
type="checkbox"
/>
{errors.isFeatured ? (
<p id="isFeatured-error">{errors.isFeatured?.message}</p>
) : null}
</div>
<div>
<label htmlFor="status">Status</label>
<select
id="status"
aria-invalid={errors.status ? 'true' : 'false'}
aria-errormessage={errors.status ? 'status-error' : undefined}
{...register('status', { required: true })}
>
<option value="">-- Choose --</option>
{PostOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{errors.status ? (
<p id="status-error">{errors.status?.message}</p>
) : null}
</div>
<div>
<button type="submit" className="rounded bg-black p-2 text-white">
Submit
</button>
</div>
</form>
)
}

There are quite a few changes, so I’m not going to overwhelm you with the differences. But let’s go over some of them together.

  1. We’ve initialized React Hook Form with useForm and generated a Type based on our PostSchema. We added our ZodType FormPayload to our resolver which useForm uses to validate our form values. We also set progressive to true so that any fields we mark as required will be natively validated by the browser.

  2. Our onSubmit function receives our validated data from our form, thanks to the handleSubmit function.

  3. We were able to replace all of our React.useState. React Hook Form manages each of our fields using the register function. Not only does this make it simpler to manage our form data, but it also prevents re-rendering when the inputs change. Cool!

  4. Since we also have access to our formState.error object, we were able to add error messages to each of our inputs. Now, when we try to submit our form without providing a title, we see an error message. We could customize this to further improve the user experience, but we’ll leave it for now.

Wow, look at how far we’ve come! By building this demo, we’ve built a robust, Typesafe application that ensures the integrity of our data regardless of where we interact with it.

We’ve also been able to pass those some of those wins to the user. By leveraging Zod and combining it with React Hook Form, we were able to reuse the same validation schema we use between the client and server to validate data of our form.

I hope you learned a lot from this demo and hope it provides a strong foundation on which you can continue to build on top of. If you have any feedback or questions about anything in this demo, please reach out!


Take a look at the full GitHub repo or check out the application on CodeSandbox