I made this write-up as a follow up to Francisco Mendes' post. While the post is a great way to get introduced to building with end-to-end type-safety in Nextjs, it was written on v9 of tRPC and as at this writing a lot has changed with v10.
I'll cover the same app setup the only changes will be tRPC setup will follow the current v10(as at this writing) configurations. Let's get to it:
What are the tools we'll use -> tRPC, Prisma, Zod
tRPC - Is a toolkit that enables us to build totally type safe applications by only using inference. It's a great tool for building APIs and it's very easy to use.
Prisma - is an ORM that enables us to write type safe queries to our database. It is a server-side library that helps developers read and write data to the database in an intuitive, efficient and safe way. It is easy to integrate into your framework of choice => Next.js, Graphql, Apollo, nest, Express e.t.c. Prisma simplifies database access, saves repetitive CRUD boilerplate and increases type safety. Its the perfect companion for building production-grade, robust and scalable web applications.
Zod - is a TypeScript-first schema builder for data validation and parsing. It's a great tool for validating data before it gets to the database. It's very easy to use and it's very fast.
Prerequisites
Of course you'll need basic knowledge of:
- Node
- Typescript
- Next.js
- Tailwind
- Npm
Okay, let's do this. Of course I'll add a link to the github repo at the bottom.
Getting Started
Next.js setup
We first spin up a next.js app and navigate to the project directory:
(note: when asked if you want src
folder select yes
. Also select @
as import alias
. this tutorial is setup that way)
npx create-next-app@latest --ts grocery-list
cd grocery-list
Install and setup Tailwind CSS
npm install @fontsource/poppins
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
configure paths in the @/tailwind.config.js
:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx}",
"./src/components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Add Tailwind directives to replace the styles in the @/src/styles/globals.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
font-family: "Poppins";
}
Setup Prisma
Install Prisma and initialize it:
npm install prisma
npx prisma init
Edit the schema.prisma
file as follows:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model GroceryList {
id Int @id @default(autoincrement())
title String
checked Boolean? @default(false)
}
Run prisma migration:
npx prisma migrate dev --name init
Now we can install prisma client:
Prisma Client is an auto-generated, type-safe query builder generated based on the models and attributes of your Prisma schema.
npm install @prisma/client
Configure tRPC
Next.js makes it easy for you to build your client and server together in one codebase. tRPC makes it easy to share types between them, ensuring type-safety for your application's data fetching.
Install dependencies:
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
@trpc/react-query: provides a thin wrapper around @tanstack/react-query. It is required as a peer dependency as an idiomatic way to handle client-side caching in React applications.
Zod: for input validation to ensure that our backend only processes requests that fit our API. You can use other validation libraries like Yup, Superstruct, io-ts if you prefer.
Enable strict mode
If you want to use Zod for input validation, make sure you have enabled strict mode in your tsconfig.json
:
"compilerOptions": {
"strict": true
}
With dependencies installed we can create a server
folder and a context.ts
file to handle our context.
The context is used to pass contextual data to all router resolvers. In this case the context we pass is the prisma instance
// @/src/server/context.ts
import * as trpc from "@trpc/server"
import * as trpcNext from "@trpc/server/adapters/next"
import { PrismaClient } from "@prisma/client"
export async function createContext(opts?: trpcNext.CreateNextContextOptions) {
const prisma = new PrismaClient()
return { prisma }
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>
Define our router
With our context created, we will now define our router and procedure helpers.
// @/src/server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from "zod"
import { Context } from "./context"
const t = initTRPC.context<Context>().create();
// Base router and procedure helpers
const router = t.router;
const publicProcedure = t.procedure;
export const serverRouter = router({
findAll: publicProcedure
.query(({ ctx }) => {
return ctx.prisma.groceryList.findMany();
}
),
insertOne: publicProcedure
.input(z.object({
title: z.string(),
})
)
.mutation(({ input, ctx }) => {
return ctx.prisma.groceryList.create({
data: { title: input.title },
});
}
),
updateOne: publicProcedure
.input(z.object({
id: z.number(),
title: z.string(),
checked: z.boolean(),
}))
.mutation(({ input, ctx }) => {
const { id, ...rest } = input;
return ctx.prisma.groceryList.update({
where: { id },
data: { ...rest },
});
}
),
deleteAll: publicProcedure
.input(z.object({
ids: z.number().array(),
}))
.mutation(({ input, ctx }) => {
const { ids } = input;
return ctx.prisma.groceryList.deleteMany({
where: { id: { in: ids } },
});
}
),
});
export type ServerRouter = typeof serverRouter;
We export our serverRouter
and its data type ServerType
Note: Avoid exporting the entire t-object since it's not very descriptive. For instance, the use of a
t
variable is common in i18n libraries.
Now we need to create an API route from Next.js to which we will handle our handler api. We will pass our router and our context (which is invoked on every request)
// @/src/pages/api/trpc/[trpc].ts
import * as trpcNext from "@trpc/server/adapters/next"
import { serverRouter } from "@/server/router"
import { createContext } from "@/server/context"
export default trpcNext.createNextApiHandler({
router: serverRouter,
createContext,
});
Now we configure the @/src/pages/_app.tsx
file
import '@/styles/globals.css'
import '@fontsource/poppins'
import type { AppType } from 'next/app';
import type { ServerRouter } from '@/server/router'
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
function getBaseUrl() {
if (typeof window === 'undefined') {
return process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: `http://localhost:3000/api/trpc`
}
return '/api/trpc'
}
const { withTRPC } = createTRPCNext<ServerRouter>({
config({ ctx }) {
const links = [
httpBatchLink({
url: getBaseUrl(),
}),
];
return { links };
},
ssr: true,
});
const App: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />
}
export default withTRPC(App);
Then we create a tPRC hook to which we will add the data type of our router as a generic on the createTRPCReact()
function from react-query, so that we can make api calls:
// @/src/utils/trpc.ts
import { createReactQueryHooks, createTRPCReact } from "@trpc/react-query";
import type { ServerRouter } from "@/server/router";
export const trpc = createTRPCReact<ServerRouter>();
Now we are done with most of the work, lets build our Frontend.
Frontend:
Lets put all the components in one folder @/src/components/index.tsx
import React, { memo } from 'react';
import type { NextPage } from 'next';
import { GroceryList } from '@prisma/client';
interface CardProps {
children: React.ReactNode;
}
export const Card: NextPage<CardProps> = ({ children }) => {
return (
<div className="h-screen flex flex-col justify-center items-center bg-slate-100">
{children}
</div>
);
};
export const CardContent: NextPage<CardProps> = ({ children }) => {
return (
<div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md">
{children}
</div>
);
};
interface CardHeaderProps {
title: string;
listLength: number;
clearAllFn?: () => void;
}
export const CardHeader: NextPage<CardHeaderProps> = ({
title,
listLength,
clearAllFn
}) => {
return (
<div className="flex flex-row items-center justify-between p-3 border-b border-slate-200">
<div className="flex flex-row items-center justify-between">
<h1 className="text-base font-medium tracking-wide text-gray-900 mr-2">
{title}
</h1>
<span className="h-5 w-5 bg-blue-200 text-blue-600 flex items-center justify-center rounded-full text-xs">
{listLength}
</span>
</div>
<button
className="text-sm font-medium text-gray-600 underline"
type='button'
onClick={clearAllFn}
>
Clear All
</button>
</div>
)
}
export const List: NextPage<CardProps> = ({ children }) => {
return <div className="overflow-y-auto h-72">{children}</div>;
};
interface ListItemProps {
item: GroceryList;
onUpdate?: (item: GroceryList) => void;
}
const ListItemComponent: NextPage<ListItemProps> = ({ item, onUpdate }) => {
return (
<div className="h-12 border-b flex items-center justify-start px-3">
<input
type="checkbox"
className="h-4 w-4 border-gray-300 rounded mr-4"
defaultChecked={item.checked as boolean}
onChange={() => onUpdate?.(item)}
/>
<h2 className="text-gray-600 tracking-wide text-sm">{item.title}</h2>
</div>
)
}
export const ListItem = memo(ListItemComponent);
interface CardFormProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
submit: () => void;
}
export const CardForm: NextPage<CardFormProps> = ({
value,
onChange,
submit
}) => {
return (
<div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md mt-4">
<div className="relative">
<input
type="text"
className="w-full py-4 pl-3 pr-16 text-sm rounded-lg"
placeholder="Grocery item name..."
onChange={onChange}
value={value}
/>
<button
type="button"
className="absolute p-2 text-white -translate-y-1/2 bg-blue-600 rounded-full top-1/2 right-4"
onClick={submit}
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
</div>
</div>
)
}
We can now import the components and work on our main page @/src/pages/index.tsx
import Head from 'next/head'
import type { NextPage } from 'next'
import { useCallback, useState } from 'react'
import { trpc } from '@/utils/trpc'
import {
Card,
CardContent,
CardForm,
CardHeader,
List,
ListItem,
} from '@/components'
import { GroceryList } from '@prisma/client'
const Home: NextPage = () => {
const [itemName, setItemName] = useState<string>('');
const { data: list, refetch} = trpc.findAll.useQuery();
const insertMutation = trpc.insertOne.useMutation({
onSuccess: () => {
refetch();
},
});
const deleteAllMutation = trpc.deleteAll.useMutation({
onSuccess: () => {
refetch();
},
});
const updateOneMutation = trpc.updateOne.useMutation({
onSuccess: () => {
refetch();
},
});
const insertOne = useCallback(() => {
if (itemName === '') return;
insertMutation.mutate({
title: itemName,
});
setItemName('');
}, [itemName, insertMutation]);
const clearAll = useCallback(() => {
if (list?.length) {
deleteAllMutation.mutate({
ids: list.map((item) => item.id),
});
}
}, [deleteAllMutation, list]);
const updateOne = useCallback(
(item: GroceryList) => {
updateOneMutation.mutate({
...item,
checked: !item.checked,
});
},
[updateOneMutation]
);
return (
<>
<Head>
<title>Grocery List</title>
<meta name="description" content="Grocery List" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Card>
<CardContent>
<CardHeader
title="Grocery List"
listLength={list?.length ?? 0}
clearAllFn={clearAll}
/>
<List>
{list?.map((item) => (
<ListItem key={item.id} item={item} onUpdate={updateOne} />
))}
</List>
</CardContent>
<CardForm
value={itemName}
onChange={(e) => setItemName(e.target.value)}
submit={insertOne}
/>
</Card>
</main>
</>
)
}
export default Home;
Final result should look like this:
You can find all this in my repo here.
I hope this helps you get started with e2e type-safety.
Top comments (3)
I loved it, keep up the great work! πͺ
Thanks for sharing itπ₯°
Does it possible to edit the list item?