DEV Community

Cover image for Fullstack Application using tRPC, Next.js, Tailwind and Prisma
David William Da Costa
David William Da Costa

Posted on • Updated on

Fullstack Application using tRPC, Next.js, Tailwind and Prisma

hai guys, so previously I just post about whether is it worth learning tRPC or not and I want to be shared the implementation of tRPC on next.js.

so before we code, we need to initialize the next.js application. open your terminal or CMD and type

npx create-next-app trpc-basic
Enter fullscreen mode Exit fullscreen mode

notes: you can give any name of your application but in this tutorial, I name it 'trpc-basic'

Image description

after you type, you need to specify the application. you can follow the settings that I made above.

after that, we need to install several libraries that we need to build full-stack application

  1. trpc
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode
  1. prisma
npm install prisma --save-dev
Enter fullscreen mode Exit fullscreen mode
  1. zod (validation)
npm install zod  
Enter fullscreen mode Exit fullscreen mode
  1. superjson (superset of JSON)
npm i superjson
Enter fullscreen mode Exit fullscreen mode

Setup Back End

open the directory that generates by create-next-app, and you need to create a folder structure like below.

.
├── prisma  # <-- if prisma is added
│   └── [..]
├── src
│   ├── pages
│   │   ├── _app.tsx  # <-- add `withTRPC()`-HOC here
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc].ts  # <-- tRPC HTTP handler
│   │   └── [..]
│   ├── server
│   │   ├── routers
│   │   │   ├── _app.ts  # <-- main app router
│   │   │   ├── post.ts  # <-- sub routers
│   │   │   └── [..]
│   │   ├── context.ts   # <-- create app context
│   │   └── trpc.ts      # <-- procedure helpers
│   └── utils
│       └── prisma.ts  # <-- your typesafe tRPC hooks
│       └── trpc.ts  # <-- your typesafe tRPC hooks
└── [..]
Enter fullscreen mode Exit fullscreen mode

note: I just followed the folder structure from tRPC docs.

after that open utils/trpc.ts and type

import { AppRouter } from '@/server/routers/_app';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
// ℹ️ Type-only import:
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
import superjson from 'superjson';

function getBaseUrl() {
  if (typeof window !== 'undefined') {
    return '';
  }
  // reference for vercel.com
  if (process.env.VERCEL_URL) {
    return `https://${process.env.VERCEL_URL}`;
  }

  // // reference for render.com
  if (process.env.RENDER_INTERNAL_HOSTNAME) {
    return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;
  }

  // assume localhost
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

/**
 * A set of strongly-typed React hooks from your `AppRouter` type signature with `createReactQueryHooks`.
 * @link https://trpc.io/docs/react#3-create-trpc-hooks
 */
export const trpc = createTRPCNext<AppRouter>({
  config({ ctx }) {
    /**
     * If you want to use SSR, you need to use the server's full URL
     * @link https://trpc.io/docs/ssr
     */
    return {
      /**
       * @link https://trpc.io/docs/data-transformers
       */
      transformer: superjson,
      /**
       * @link https://trpc.io/docs/links
       */
      links: [
        // adds pretty logs to your console in development and logs errors in production
        loggerLink({
          enabled: (opts) =>
            process.env.NODE_ENV === 'development' ||
            (opts.direction === 'down' && opts.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
      /**
       * @link https://react-query.tanstack.com/reference/QueryClient
       */
      // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
    };
  },
  /**
   * @link https://trpc.io/docs/ssr
   */
  ssr: false,
});
Enter fullscreen mode Exit fullscreen mode

so let me explain this, the code above is called custom hooked for use of tRPC in your application and there are several configs for data transformer, logger, etc.

next step, open utils/prisma.ts and type

/**
 * @link https://prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices
 */
import { PrismaClient } from '@prisma/client';

export const prisma: PrismaClient =
  (global as any).prisma || new PrismaClient();

if (process.env.NODE_ENV !== 'production') {
  (global as any).prisma = prisma;
}
Enter fullscreen mode Exit fullscreen mode

this configuration is for the integration prisma client with your application so you can use it.

next step, you need to fill server/trpc.ts with code below

import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import { Context } from "./context";

const t = initTRPC.context<Context>().create({
    transformer: superjson,
});

export const router = t.router;
export const procedure = t.procedure;

Enter fullscreen mode Exit fullscreen mode

this configuration is for initializing tRPC with context. maybe I will explain about context, so context holds data that all of your tRPC procedures will have access to, and is a great place to put things like database connections.

after that, we need to config context everytime request from client. open server/context.ts


import * as trpc from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import { prisma } from '@/utils/prisma';

// create context based of incoming request
export const createContext = async (
  opts?: trpcNext.CreateNextContextOptions,
) => {
  return {
    req: opts?.req,
    prisma,
    post: prisma.post,
  };
};
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

we just finished to initialize tRPC but we can't use it because we need initialize router. so, open router/_app.ts

import { router } from "../trpc";
import { postRouter } from "./post";

export const appRouter = router({
    post: postRouter,
})

export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

we create AppRouter to merge router that represent feature. (try to read SOLID principle for make a better code)

after we merge router, type in router/post.ts

import { z } from "zod";
import { router, procedure } from "../trpc";

export const postRouter = router({
    all: procedure.query(({ctx}) => {
        return ctx.post.findMany({
            orderBy: {
                createdAt: 'asc'
            }
        })
    }),
    add: procedure.input(
        z.object({
            title: z.string().min(5),
            description: z.string(),
            author: z.string(),
        }),
    )
    .output(z.object({
        title: z.string().min(5),
        description: z.string(),
        author: z.string(),
    }))
    .mutation(async ({ctx, input}) => {
        const todo = await ctx.post.create({
            data: input
        })

        return todo
    })
})
Enter fullscreen mode Exit fullscreen mode

in this tutorial, we just add some features like getting all posts and adding posts into the database. but before we add the post from the client, we need to validate input with a library called Zod. If the post was success validate then we add the post into the database using the function create(). create() is a function from prisma library that makes it easy to push the data into the database.

next step, open pages/api/trpc/[trpc].ts and then type

import * as trpcNext from "@trpc/server/adapters/next";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/context";

export default trpcNext.createNextApiHandler({
    router: appRouter,
    createContext,
    onError({ error }) {
        if (error.code === 'INTERNAL_SERVER_ERROR') {
          // send to bug reporting
          console.error('Something went wrong', error);
        }
      },
})
Enter fullscreen mode Exit fullscreen mode

with this config, we can access tRPC API in next.js and we add appRouter from the router that we created before so you can use it to get all posts and add posts.

after that, we need to make model for database. so, open prisma/schema.prisma and type

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id          Int       @id @default(autoincrement())
  title       String    
  description String    
  author      String    
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

Enter fullscreen mode Exit fullscreen mode

for more about prisma ORM you can read this. Prisma schema.

so basically, we just add columns into tables like id, title, description, etc with data types and you can also edit database providers such as SQL, PostgreSQL, MongoDB and etc.

before we migrate the database, we need to create .env file in root folder and type

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL=file:./posting-trpc.db"
Enter fullscreen mode Exit fullscreen mode

notes: you can customize DATABASE_URL with your databases that you use.

next step, run this code in cmd or terminal (root folder)

prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

after that, we can check migration on folder prisma/migrations. if it's not empty, database migrate was success.

Setup Front End

we're gonna use tailwind for styling css. so, type in your cmd or terminal

npm install -D tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

after all that run properly, type

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

this code will initialize tailwind.config.js. open the file and add this code into content

"./src/**/*.{js,ts,jsx,tsx}",
Enter fullscreen mode Exit fullscreen mode

next step, open styles/globals.css and then edit the css with this code

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

in this tutorial, i just use tailwind for css and didn't use css native

how server that we made can comunicate with front-end? we can use trpc utility that we made before and wrapped the _app.tsx. the code will be like this

import '@/styles/globals.css'
import { trpc } from '@/utils/trpc'
import type { AppType } from 'next/app'


const App: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />
}

export default trpc.withTRPC(App);
Enter fullscreen mode Exit fullscreen mode

after that, we can use tRPC on views or pages file. in this case index.tsx. edit the index.tsx like down below.

import { trpc } from '@/utils/trpc'
import { Post } from '@/types/post';
import { useState } from 'react';

const ListItem = ({post}: {post: Post}) => {
  return (
    <tr key={post.id} className={`${post.id % 2 === 1 ? 'bg-gray-200' : null}`}>
        <td className='px-4 py-3.5 text-sm font-normal text-left rtl:text-right text-gray-800'>{post.title}</td>
        <td className='px-4 py-3.5 text-sm font-normal text-left rtl:text-right text-gray-800'>{post.description}</td>
        <td className='px-4 py-3.5 text-sm font-normal text-left rtl:text-right text-gray-800'>{post.author}</td>
        <td className='px-4 py-3.5 text-sm font-normal text-left rtl:text-right text-gray-800'>{post.createdAt.toLocaleString()}</td>
    </tr>
  )
}

type alertProps = {
  error: any;
  type: string;
}
const Alert = ({error, type}: alertProps) => {
  if(error === ""){
    return null
  }
  return (
    <div className={`rounded-lg ${type === 'error' ? "bg-red-500" : null} text-white p-2`}>
        <p>{error['message']}</p>
      </div>
  )
}

export default function Home() {
  const utils = trpc.useContext()
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [author, setAuthor] = useState("");
  const [errorMessage, setErrorMessage] = useState("");

  const allPost = trpc.post.all.useQuery(undefined, {
    staleTime: 3000,
  })

  const submitPost = () => {
    addPost.mutate({title, description, author})
  }

  const addPost = trpc.post.add.useMutation({
    async onMutate() {
      await utils.post.all.cancel();
    },

    async onError(error) {
      console.log('error: ',error.data)
      setErrorMessage(error.message)
    },

    async onSuccess({title, description, author}) {
      const post = allPost.data ?? [];

      utils.post.all.setData(undefined, [
        ...post, 
        {
          id: Math.random(),
          title, 
          description, 
          author,
          createdAt: new Date(),
          updatedAt: new Date(),
        }
      ]
      )
    }
  });

  return (
    <div>
      <Alert
        error={errorMessage}
        type={'error'}
      />
      <h1 className='text-center my-5 font-semibold text-2xl'>create new post</h1>
      <div className='container w-1/2 mx-auto'>
      <div className='flex flex-col gap-4 mb-3'>
        <label className='font-bold text-lg'>Title</label>
        <input onChange={(e) => setTitle(e.target.value)} className="border p-2 rounded-lg" type={'text'} placeholder={"title here..."} />
      </div>
      <div className='flex flex-col gap-4 mb-3'>
        <label className='font-bold text-lg'>description</label>
        <textarea onChange={(e) => setDescription(e.target.value)} className="border p-2 rounded-lg" placeholder={"description here..."}></textarea>
      </div>
      <div className='flex flex-col gap-4'>
        <label className='font-bold text-lg'>author</label>
        <input onChange={(e) => setAuthor(e.target.value)} className="border p-2 rounded-lg" type={'text'} placeholder={"author here..."} />
      </div>
      <div className='text-center'>
      <button onClick={submitPost} className='bg-green-400 text-white p-3 rounded-lg mt-3 active:bg-green-600'>Post now</button>
      </div>
      </div>
      <h1 className='text-center my-5 font-semibold text-2xl'>list post</h1>
      <table className="table-auto mx-auto mt-5 border">
        <thead className='border'>
        <tr>
          <th className="px-4 py-3.5 text-sm font-bold text-center rtl:text-right text-gray-800">Title</th>
          <th className="px-4 py-3.5 text-sm font-bold text-center rtl:text-right text-gray-800">Description</th>
          <th className="px-4 py-3.5 text-sm font-bold text-center rtl:text-right text-gray-800">Author</th>
          <th className="px-4 py-3.5 text-sm font-bold text-center rtl:text-right text-gray-800">Created At</th>
        </tr>
        </thead>
        <tbody>
        {
            allPost.data?.map((data) => {
                return (
                  <ListItem post={data} key={data.id} />
                )
            })
          }
        </tbody>
      </table>
    </div>
  )
};
Enter fullscreen mode Exit fullscreen mode

okay, I will explain this code above, so basically we want to get the data from tRPC by initializing tRPC in views and then we access the function from the router we made (in this case object name called all). after that, we want to add the post from the input we created in function postSubmit({title, description, author}).

you notice that we use useMutation for real-time fetch data, so every time we load again the page, data will be fetched again and we didn't need to reload the page to see data changed.

I think that from me, I wish this tutorial will help you to understand tRPC and how to create full-stack using next.js. thanks, guys :)

Top comments (0)