Create the Full stack CRUD app using tRPC, Next.js, TypeScript, Chakra UI, and Prisma ORM

@chamisg
8 min readMay 25, 2023

--

Introduction

In this tutorial, you will learn how to create a full-stack CRUD application using some of the latest web development technologies such as tRPC, Next.js, TypeScript, Chakra UI, and Prisma ORM. By the end of this tutorial, you will have built an application that can perform all standard CRUD operations (Create, Read, Update, Delete) on a simple todo list.

Prerequisites

Before you start, you should have a basic understanding of JavaScript and web development concepts. You should also have the following installed on your machine:

  • Node.js (Version 14 or higher)
  • npm or yarn
  • A text editor (VS Code preferred)

Creating a New Next.js TypeScript Project

To start, let’s create a new Next.js project. Open your terminal and run:

npx create-next-app@12 --typescript trpc-next-prisma-todo-app

Once it’s done, cd into the project and open it in your vscode editor:

cd trpc-next-prisma-todo-app && code .

In the next section, we’ll set up the backend with tRPC & Prisma ORM.

Creating the Server

Installing all the required dependencies including tRPC for server, and Prisma for our database.

yarn add @trpc/server @trpc/client @trpc/react @trpc/next @trpc/react-query @tanstack/react-query prisma @prisma/client superjson

Let’s start by creating the server using tRPC. We'll be using tRPC as our primary tool for creating the API because of its simplicity.

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.

In order to do this, create new folder server/ in root and create new files called trpc.ts, context.ts, prisma.ts, env.jsin the server/ folder of your project with the following code:

// context.ts

/* eslint-disable @typescript-eslint/no-unused-vars */
import * as trpc from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CreateContextOptions {
// session: Session | null
}

export async function createContextInner(_opts: CreateContextOptions) {
return {};
}

export type Context = trpc.inferAsyncReturnType<typeof createContextInner>;

export async function createContext(
opts: trpcNext.CreateNextContextOptions,
): Promise<Context> {
const { req, res } = opts;

return await createContextInner({
req,
res,
prisma,
});
}
// trpc.ts

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

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

/**
* Create a router
*/
export const router = t.router;

/**
* Create an unprotected procedure
**/
export const publicProcedure = t.procedure;

export const middleware = t.middleware;

export const mergeRouters = t.mergeRouters;// createRouter.ts

import { router } from '@trpc/server'
import { Context } from './createContext'
import superjson from 'superjson'

export const createRouter = () => {
return router<Context>().transformer(superjson)
}
// prisma.ts

/**
* Instantiates a single instance PrismaClient and save it on the global object.
* @link https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices
*/
import { env } from './env';
import { PrismaClient } from '@prisma/client';

const prismaGlobal = global as typeof global & {
prisma?: PrismaClient;
};

export const prisma: PrismaClient =
prismaGlobal.prisma ||
new PrismaClient({
log:
env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

if (env.NODE_ENV !== 'production') {
prismaGlobal.prisma = prisma;
}
// env.js

/* eslint-disable @typescript-eslint/no-var-requires */
const { z } = require('zod');

/*eslint sort-keys: "error"*/
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'test', 'production']),
});

const env = envSchema.safeParse(process.env);

if (!env.success) {
console.error(
'❌ Invalid environment variables:',
);
process.exit(1);
}
module.exports.env = env.data;

Also, we need to define the tRPC’s HTTP response handler in pages/api/trpc/[trpc.ts] .

/**
* This file contains tRPC's HTTP response handler
*/
import * as trpcNext from '@trpc/server/adapters/next';
import { createContext } from '@/server/context';
import { appRouter } from '@/server/route/app.router';

export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
onError({ error }) {
if (error.code === 'INTERNAL_SERVER_ERROR') {
console.error('Something went wrong', error);
}
},
batching: {
enabled: true,
},
});

And then create new files called app.router.ts, todo.router.tsin the server/route/ folder of your project with the following code:

// app.router.ts

import { publicProcedure, router } from '../trpc';
import { todoRouter } from "./todo.router";


export const appRouter = router({
healthcheck: publicProcedure.query(() => 'yay!'),
todo: todoRouter,
});

export type AppRouter = typeof appRouter;

Now let’s setting up that will connect our frontend with the backend.
Create a new folder called utils/ in your project’s root folder and add a new file called trpc.ts

// trpc.ts

import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { NextPageContext } from 'next';
import superjson from 'superjson';
import { AppRouter } from '@/server/route/app.router';

export interface SSRContext extends NextPageContext {
status?: number;
}

export const trpc = createTRPCNext<AppRouter, SSRContext>({
config({ ctx }) {
return {
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: `/api/trpc`,
headers() {
if (ctx?.req) {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
connection: _connection,
...headers
} = ctx.req.headers;
return {
...headers,
'x-ssr': '1',
};
}
return {};
},
}),
],
queryClientConfig: {
defaultOptions: {
queries: {
// staleTime: 60,
refetchOnWindowFocus: false
}
}
},
};
},
ssr: false,

});

export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;

Now, we will create a simple CRUD API for our todo list. `getAll` query returns all the available TODOs in descending order via Prisma ORM. `create`, `update`, `delete` mutation creates/updates/delete a new TODO and saves it to the database.

// src/server/route/todo.router.ts

import { createTodoSchema, updateTodoSchema, deleteTodoSchema } from "@/schema/todo.schema";
import { router, publicProcedure } from "../trpc";
import { z } from "zod";
import { prisma } from "@/server/prisma";

export const usersRouter = router({
create: publicProcedure
.input(createTodoSchema).mutation(async ({ input }) => {
const { title, description } = input

const result = await prisma.todo.create({
data: {
title: title,
description: description,
},
});
return {
status: 201,
message: "Todo created successfully",
result: result,
};
}),
update: publicProcedure
.input(updateTodoSchema).mutation(async ({ input }) => {
const { id, title, description } = input

const result = await prisma.todo.update({
where: { id },
data: {
title: title,
description: description,
},
});
return {
status: 201,
message: "Todo updated successfully",
result: result,
};
}),
delete: publicProcedure
.input(deleteTodoSchema).mutation(async ({ input }) => {
const { id } = input

const result = await prisma.todo.delete({
where: { id },
});
return {
status: 201,
message: "Todo updated successfully",
result: result,
};
}),
getAll: publicProcedure
.query(async () => {
return await prisma.todo.findMany()
}),
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
return await prisma.todo.findFirstOrThrow({
where: { id: input }
})
})
});

And then we will create the schema file for validation input using the zod.

Zod is a TypeScript-first schema declaration and validation library.
With Zod, we declare a validator once and Zod will automatically infer the static TypeScript type. It’s easy to compose simpler types into complex data structures.

yarn add zod
// src/schema/todo.schema.ts

import { z } from "zod"

export const createTodoSchema = z.object({
title: z.string(),
description: z.string(),
createdAt: z.date().optional(),
updatedAt: z.date().optional()
})

export const updateTodoSchema = z.object({
id: z.number(),
title: z.string(),
description: z.string(),
})

export const deleteTodoSchema = z.object({
id: z.number(),
})

export type createTodoSchema = z.TypeOf<typeof createTodoSchema>
export type updateTodoSchema = z.TypeOf<typeof updateTodoSchema>
export type deleteTodoSchema = z.TypeOf<typeof deleteTodoSchema>

Creating the Frontend with Chakra UI

Now let’s create the front-end of our application using `Next.js`, `TypeScript`, and `Chakra UI`.

Chakra UI provides flexible and accessible components that you can use to build beautiful React applications. Let’s install it first:

yarn add @chakra-ui/react @chakra-ui/next-js @emotion/react @emotion/styled framer-motion

Here we are wrapping the `Component` using Chakra UI’s CSS theme provider.

// _app.tsx

import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { ChakraProvider } from "@chakra-ui/react";
import { theme } from '@/styles/theme';

function MyApp({ Component, pageProps }: AppProps) {

return (
<ChakraProvider theme={theme}>
<Component {...pageProps} />
</ChakraProvider>
)
}

export default MyApp
// _document.tsx

import NextDocument, { Html, Head, Main, NextScript } from 'next/document';
import { ColorModeScript } from '@chakra-ui/react';

export default class Document extends NextDocument {
render() {
return (
<Html lang='en'>
<Head>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;600;700;800&display=swap" rel="stylesheet" />
<meta name="description" content="tRPC Next.js Prisma Chakra UI TypeScript Todo content" key="desc" />
<meta property="og:title" content="tRPC Next.js Prisma Chakra UI TypeScript Todo" />
<meta
property="og:description"
content="tRPC Next.js Prisma Chakra UI TypeScript Todo App"
/>
<meta
property="og:image"
content="https://example.com/images/cool-page.jpg"
/>
</Head>
<body>
{/* Make Color mode to persists when you refresh the page. */}
<ColorModeScript />
<Main />
<NextScript />
</body>
</Html>
);
}
}

Connecting the Frontend to the Backend

Now let’s create a API client that will connect our frontend with the backend.

In the _app.tsx file, we will connect the trpc and next frontend . Here is updated code.

// _app.tsx

import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { ChakraProvider } from "@chakra-ui/react";
import { theme } from '@/styles/theme';
import { trpc } from '@/utils/trpc';

function MyApp({ Component, pageProps }: AppProps) {

return (
<ChakraProvider theme={theme}>
<Component {...pageProps} />
</ChakraProvider>
)
}

export default trpc.withTRPC(MyApp);

Now we have created the `AppRouter` object which allows us to call the server functions and retrieve data.

Creating Database and ORM

Now we’ll use `Prisma ORM` to generate the necessary database tables and models. Run the following command to initialize Prisma:

npx prisma init

This will initialize the Prisma project and create a `schema.prisma` file in your project root. Replace the contents of the file with the following code:

// 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 = "mysql"
url = env("DATABASE_URL")
}

model Todo {
id Int @id @default(autoincrement())
title String
description String
createdAt DateTime @default(now()) @db.Timestamp(0)
updatedAt DateTime @default(now()) @db.Timestamp(0)
}

In this case, we will using the MySQL and so we need to set MySQL URL on env file.

Next, we’ll create the Prisma client instance. Add a new file called prisma.ts in utils/ folder and add the following code:

// prisma.ts

import { PrismaClient } from '@prisma/client';

declare global {
var prisma: PrismaClient | undefined;
}

export const prisma = global.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}

Now run the command to migrate your database:

npx prisma migrate dev --name init

Your database tables should have been created.

Building CRUD Functionality

Finally, let’s create the CRUD functionality for our application. We’ve already created routers which uses Prisma ORM to insert the data in the `Todo` table.

Here we are rendering a simple list of all available todos and providing the ability to create a new one using the form. `useQuery` is used for fetching data from the backend and `useMutation` is used to set up the creation of new TODOs.

Conclusion

In this tutorial, we learned how to create a full-stack CRUD application using tRPC, Next.js, TypeScript, Chakra UI, and Prisma ORM. We started by setting up our development environment and creating the backend using `tRPC` and frontend using `Chakra UI`.

You can find all code in my Github repository.

Enjoy your coding!

--

--