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.
Install Dependencies
-
Start by running
npx create-next-app@14
and use the following options: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.
-
Next, let’s install the some of other tools we’re going to use. I’ll explain each below:
@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 installv11
which is compatible with TanStack Queryv5
. If you’re using tRPCv10
, use TanStack Queryv4
)@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:
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:
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
-
Initialize tRPC. This will allow us to build and access our global tRPC functions throughout our application.
-
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.
-
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 usecaller
.
-
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.
-
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.
We’ll wrap our application with that provider:
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.
Then, to set up our database and generate Types from our Prisma schema run the following:
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.
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.
Then import that into your base “server” router:
There are a few things happening in the file above, let’s walk through it.
-
getAll
andcreate
are the procedures we can call from thetrpc
object. We’ll access them by usingtrpc.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 apublicProcedure.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 ofpayload
that contains ourPost
object, but without theid
,createdAt
, andupdatedAt
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 ourinput
object and we store it in our database using Prisma. -
Lastly, remember how our Prisma and Zod
status
Types differed? OurPostSchema
ensures thatstatus
has the correct value when it reaches the serverinput
step. We also usePostSchema.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.
We’ll add those components to our home page:
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:
Then we’ll add it to tRPC:
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.
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.
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.
Let’s go over what’s happening here:
- First, we’re canceling queries that fire at the same time to avoid issues with our optimistic update competing with refetched data.
- Then, we store our
previous
data for safe-keeping. We’ll use this later to roll back our changes if something goes wrong. - After that, we manually update the cache by merging the old data with the new data. Because we’re not sending an
id
,createdAt
, orupdatedAt
, we’ll need to set those manually here. We return theupdated
data and theprevious
data in order to pass it to the next callback. - If something should go wrong for any reason, we use
onError
to set our cache back to the data it had previously. - 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:
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:
Now we’ll update our form to use React Hook 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.
-
We’ve initialized React Hook Form with
useForm
and generated a Type based on ourPostSchema
. We added our ZodTypeFormPayload
to our resolver whichuseForm
uses to validate our form values. We also setprogressive
totrue
so that any fields we mark asrequired
will be natively validated by the browser. -
Our
onSubmit
function receives our validated data from our form, thanks to thehandleSubmit
function. -
We were able to replace all of our
React.useState
. React Hook Form manages each of our fields using theregister
function. Not only does this make it simpler to manage our form data, but it also prevents re-rendering when the inputs change. Cool! -
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