Building a Fullstack Next.js app -> tRPC(v10), Tailwind, Prisma

kharioki

Tony Kharioki

Posted on March 18, 2023

Building a Fullstack Next.js app -> tRPC(v10), Tailwind, Prisma

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
Enter fullscreen mode Exit fullscreen mode

Install and setup Tailwind CSS

npm install @fontsource/poppins
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

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: [],
}
Enter fullscreen mode Exit fullscreen mode

Add Tailwind directives to replace the styles in the @/src/styles/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

* {
    font-family: "Poppins";
  }
Enter fullscreen mode Exit fullscreen mode

Setup Prisma

Install Prisma and initialize it:

npm install prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Run prisma migration:

npx prisma migrate dev --name init

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

@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
}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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>(); 

Enter fullscreen mode Exit fullscreen mode

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>
  )
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Final result should look like this:

grocery-list


You can find all this in my repo here.

I hope this helps you get started with e2e type-safety.

💖 💪 🙅 🚩
kharioki
Tony Kharioki

Posted on March 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related