When we create a TypeScript project that has both a Rest Api and a web app, it becomes challenging to keep type definitions concise in the long run.
If we created a GraphQL Api, the conversation might change because we can use code generation, but we still have to maintain the schema on the backend side.
So basically, in both options, we always have to maintain a schema or some sort of type definition.
Introduction
This is where tRPC comes in, with this toolkit it is possible to create a totally type safe application by only using inference. When we made a small change in the backend, we ended up having those same changes reflected in the frontend.
Prerequisites
Before going further, you need:
- Node
- TypeScript
- Next.js
- Tailwind
- NPM
In addition, you are expected to have basic knowledge of these technologies.
Getting Started
Project setup
Let's setup next.js and navigate into the project directory:
npx create-next-app@latest --ts grocery-list
cd grocery-list
In tsconfig.json
we will add a path alias to make it easier to work with relative paths:
// @/tsconfig.json
{
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
],
}
},
// ...
}
Install Tailwind CSS:
npm install @fontsource/poppins
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
In the file tailwind.config.js
add the paths to the pages and components folders:
// @/tailwind.config.js
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx}",
"./src/components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Now let's add the Tailwind directives to our globals.css
:
/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
font-family: "Poppins";
}
As you may have noticed, all our source code, including the styles, will be inside the /src
folder.
Setup Prisma
First of all let's install the necessary dependencies:
npm install prisma
Now let's initialize the prisma setup:
npx prisma init
And let's add the following schema to our schema.prisma
:
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)
}
With the schema defined, you can run our first migration:
npx prisma migrate dev --name init
Finally we can install the prisma client:
npm install @prisma/client
With the base configuration of our project complete, we can move on to the next step.
Configure tRPC
First of all, let's make sure that tsconfig.json
has strict mode enabled:
// @/tsconfig.json
{
"compilerOptions": {
// ...
"strict": true
},
// ...
}
Then we can install the following dependencies:
npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query
With our dependencies installed we can create the /server
folder and we can create our context.
The context is used to pass contextual data to all router resolvers. And in our context we will just pass our prism client 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>;
With our context created (createContext()
) and the data types inferred from it (Context
), we can move on to defining our router, but before that it is important to keep in mind that:
- An endpoint is called a procedure;
- A procedure can have two types of operations (query and mutation);
- Queries are responsible for fetching data, while mutations are responsible for making changes to the data (server-side).
With these points in mind we can now define our router:
// @/src/server/router.ts
import * as trpc from "@trpc/server";
import { z } from "zod";
import { Context } from "./context";
export const serverRouter = trpc
.router<Context>()
.query("findAll", {
resolve: async ({ ctx }) => {
return await ctx.prisma.groceryList.findMany();
},
})
.mutation("insertOne", {
input: z.object({
title: z.string(),
}),
resolve: async ({ input, ctx }) => {
return await ctx.prisma.groceryList.create({
data: { title: input.title },
});
},
})
.mutation("updateOne", {
input: z.object({
id: z.number(),
title: z.string(),
checked: z.boolean(),
}),
resolve: async ({ input, ctx }) => {
const { id, ...rest } = input;
return await ctx.prisma.groceryList.update({
where: { id },
data: { ...rest },
});
},
})
.mutation("deleteAll", {
input: z.object({
ids: z.number().array(),
}),
resolve: async ({ input, ctx }) => {
const { ids } = input;
return await ctx.prisma.groceryList.deleteMany({
where: {
id: { in: ids },
},
});
},
});
export type ServerRouter = typeof serverRouter;
Based on the previous snippet, you may have noticed the following:
- The data type of our context was used as a generic in our router so that we have the typed context object (in order to have access to our prisma instance);
- Our backend has a total of four procedures;
- We exported our router (
serverRouter
) and its data type (ServerRouter
).
With our router configured, we need to create a API route from Next.js to which we will add our handler api. In 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 it's time to configure the _app.tsx
file as follows:
// @/src/pages/_app.tsx
import "../styles/globals.css";
import "@fontsource/poppins";
import { withTRPC } from "@trpc/next";
import { AppType } from "next/dist/shared/lib/utils";
import type { ServerRouter } from "@/server/router";
const App: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};
export default withTRPC<ServerRouter>({
config({ ctx }) {
const url = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: "http://localhost:3000/api/trpc";
return { url };
},
ssr: true,
})(App);
Then we will be create the tRPC hook, to which we will add the data type of our router as a generic on the createReactQueryHooks()
function, so that we can make api calls:
// @/src/utils/trpc.ts
import type { ServerRouter } from "@/server/router";
import { createReactQueryHooks } from "@trpc/react";
export const trpc = createReactQueryHooks<ServerRouter>();
Create the Frontend
First let's deal with the components of our application, to be simpler I'll put everything in a single file in the /components
folder.
Starting with the card, let's create the card's container, header and content:
// @/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>
);
};
// ...
Now that we've created our card, we can create the components of our list:
// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";
// ...
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="w-4 h-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);
// ...
Finally, just create our form to add new elements to the list:
// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";
// ...
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
className="w-full py-4 pl-3 pr-16 text-sm rounded-lg"
type="text"
placeholder="Grocery item name..."
onChange={onChange}
value={value}
/>
<button
className="absolute p-2 text-white -translate-y-1/2 bg-blue-600 rounded-full top-1/2 right-4"
type="button"
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>
);
};
And with everything ready, we can start working on our main page. Which can be as follows:
// @/src/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
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.useQuery(["findAll"]);
const insertMutation = trpc.useMutation(["insertOne"], {
onSuccess: () => refetch(),
});
const deleteAllMutation = trpc.useMutation(["deleteAll"], {
onSuccess: () => refetch(),
});
const updateOneMutation = trpc.useMutation(["updateOne"], {
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),
});
}
}, [list, deleteAllMutation]);
const updateOne = useCallback(
(item: GroceryList) => {
updateOneMutation.mutate({
...item,
checked: !item.checked,
});
},
[updateOneMutation]
);
return (
<>
<Head>
<title>Grocery List</title>
<meta name="description" content="Visit www.mosano.eu" />
<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;
After all these steps in this article, the expected final result is as follows:
If you just want to clone the project and create your own version of this app, you can click on this link to access the repository for this article.
I hope you found this article helpful and I'll see you next time.
Top comments (38)
Tailwind is evil, it pollutes your jsx code and it cannot do some of the basic stuff that UI frameworks offer these days, like media queries in js (
useMediaQuery
). Basically what Tailwind does is compile a huge list of classes before any js code is actually run. This means you cannot use any kind of variable value from js. The only way to do so, is by using CSS variables and change the value of those in runtime using JS. A very cumbersome approach, thats very difficult to understand for people who didnt write the code (or if you dont work on the project for a while). You cannot access any of the values used in Tailwind from JS, so you're completely locked into doing things "their way" (very opinionated framework). Modern UI frameworks offer ways to access the defined values, like your theme/pallete or the defined breakpoints.All Tailwind does is create obscure classnames that you have to learn. Instead of using the CSS that you already know.
If you refuse to use a modern UI framework. Why not just use the
style
property instead of Tailwind CSS...Oh no! It's a few characters longer! facepalm
I think the fact that Tailwind is utility-first is ideal for creating an application, whether it's web or mobile (I've used it for several months with React Native) if you have a specific design that you want to implement and the flexibility you have is incredible .
A lot of people like to comment on the fact that we have to memorize several classes, but over time it becomes totally natural when compared to other css frameworks.
Regarding the "pollution" of jsx and making it more difficult to read, there are several ways to accomplish this, the simplest is to create a variable outside the functional component and add styles to it (but you can also use css and scss modules, twin, etc). Another aspect is that we can also create our design system with tailwind, from colors, spacing, etc.
I don't put myself as a fan of anything, because in reality the stack and libs change from project to project, but I think it's incorrect to look only at some aspects and not at others (this discussion could take days).
CSS is already natural. Why learn something else that offers no extra benefit. Thats just arrogant framework behavior if you ask me. Tailwind CSS is basically saying: "We're better than the people who came up with CSS, so we're just going to rename a small percentage of CSS syntax to custom classnames, cuz we're rebel bruh!"
If a framework forcefully introduces problems that you need to fix in order to stay anywhere near clean code principles, why use the framework at all... Its a bad framework. <- emphasis on period.
You can do that with modern UI frameworks aswell, most of them offer ways to define/override pretty much every aspect and actually offer good documentation on how to do so. For example in Mui: mui.com/material-ui/customization/...
And we havent even began to touch on the fact that you definitely do not want to run any a11y linting on an application using Tailwind.
The discussion only takes days because the Tailwind fanboys obsessively want to stay rebel and produce web applications that do not follow any web standard.
I think you may find Chakra UI more suitable UI solution for you
chakra-ui.com/
Chakra UI is very good too yes, I have more experience with Mui though.
In my opinion Chakra UI is the best UI framework.
Everyday a TW hater bringing JS oriented style as argument to replace CSS.
No, I actually worked with it and discovered the many things Tailwind just cannot do, without very dirty workarounds. Also, its Tailwind that is trying to replace CSS, you're writing class names, not CSS.
Like what?
You understood CSS wrong. It's all about composing classes and reuse it. You should write more reusable CSS as you can to do not repeat it again and again. Tailwind provides a shortcut to skip the writing CSS for general purpose styles and avoid a lot of duplicates.
I use both, CSS Utility framework (Tailwind) and CSS Components Framework (Bootstrap, Antd, etc). In my opinion, Tailwind best fit for team who work together (frontend, backend, UI/UX), but Bootstrap or Antd fit for Fullstack Dev.
this write up assumes you're using tRPC v9.
for anyone using tRPC v10, I made a write up on how to configure yuour project. checkout this link
Otherwise thank you Francisco
Super article !👏
Btw I hate haters and I love TW !
I worked with it on recents projects and I finally felt in love with !
That's said, I'm bootstrapping an NextJS app. TW and Prisma are already packed. And your article will help me deciding between graphql clients like Apollo and RPC ones like tRPC
Thanks mate
Glad to know! 🙌 I hope you like the development experience 🤞
Nice content.
It would be good to mention t3.gg so people can find more content on this stack. I imagine you got inspired by his work too.
Also in the Prerequisites section the only thing we need are Node/npm, because next is added during initialization and the other are added on the next steps.
Keep the good work.
Thanks for the feedback! 👊 I've been using this stack for a few months now in personal projects, it's super fast to prototype an app, but it never crossed my mind to publish an article about it. And great point to mention Theo, I definitely recommend his content, not just for this stack but for his didactic approach, he's one of the few that isn't "tutorial-ish".
I'd suggest checking out Deepkit RPC.
Great article! This is exact tech stack I was looking for.
Glad to know! 👊
Great content here. The project is minimal but it teaches many vital techniques. Thanks Mendes
Thanks for the feedback, this is exactly the intended result in my articles. Small examples, but with enough bases to do more 👊
I've been coding this tuto and I shared it on Github here:
github.com/francisrod01/fullstack-...
I found a few mistakes in the tutorial, such as:
@prisma/client
doesn't have GroceryList componenttrpc.useMutation
should mention "groceries." in its reserved wordPreview:
Thanks for the comment 💪, I already fixed the import of the components. Regarding the problems you are having with the grocery list data types, they are probably related to the lack of data types generation or a new migration.
And here square brackets should be used and not curly brackets:
Thanks, but I didn't find the solution for the import of the components, neither in the repo.
I mean along the lines of html element roles, like role=button for clickable elements for screen readers, proper aria labels for label/input relationships etc. There's a lot, more than I care to maintain myself, so having a UI framework that takes care of that for me is a big help.
Wanted to throw this in the mix, headlessui.com/ which is a project from the creators of tailwind. It is a library of unstyled accessible UI components.
That is a good initiative, however I am still missing the most used unaccessible elements: inputs and buttons. And how many people using Tailwind are actually using headless UI though? I reckon not many, atleast I have not seen it out in the wild yet.