Fullstack Application using tRPC, Next.js, Tailwind and Prisma
David William Da Costa
Posted on March 25, 2023
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
notes: you can give any name of your application but in this tutorial, I name it 'trpc-basic'
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
- trpc
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query
- prisma
npm install prisma --save-dev
- zod (validation)
npm install zod
- superjson (superset of JSON)
npm i superjson
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
└── [..]
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,
});
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;
}
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;
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>;
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;
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
})
})
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);
}
},
})
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
}
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"
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
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
after all that run properly, type
npx tailwindcss init -p
this code will initialize tailwind.config.js. open the file and add this code into content
"./src/**/*.{js,ts,jsx,tsx}",
next step, open styles/globals.css and then edit the css with this code
@tailwind base;
@tailwind components;
@tailwind utilities;
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);
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>
)
};
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 :)
Posted on March 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.