DEV Community

Cover image for Build Full Stack App with React, Fastify, tRPC, Prisma ORM and Turborepo
Francisco Mendes
Francisco Mendes

Posted on • Edited on

Build Full Stack App with React, Fastify, tRPC, Prisma ORM and Turborepo

In today's article we are going to create a full stack application using a monorepo. Our monorepo will consist of two packages, an api and a web app, which we will create step by step.

Introduction

In this world of monorepos there are several tools that help us to create and manage our packages/apps.

And the overwhelming majority of these tools focus on solving just one problem in a very effective way, there are, for example, tools that deal with the versioning of our packages, others generate the build cache, linting and tests, others deal with the from publishing and deploying.

But the purpose of today's article is to use knowledge you already have about creating node apis and web applications in React and simply add some tools to improve our development and delivery experience.

Prerequisites

Before going further, you need:

  • Node
  • Yarn
  • TypeScript
  • React

In addition, you are expected to have basic knowledge of these technologies.

Getting Started

With these small aspects in mind we can now move on to boostrap our monorepo.

Yarn workspaces

First of all let's create our project folder:



mkdir monorepo
cd monorepo


Enter fullscreen mode Exit fullscreen mode

Then we initialize the repository:



yarn init -y


Enter fullscreen mode Exit fullscreen mode

And in our package.json we added the following properties:



{
  "private": true,
  "workspaces": [
    "packages/*"
  ],
}


Enter fullscreen mode Exit fullscreen mode

Now we have our workspace configured, and we will have the following benefits:

  • Although the dependencies are installed in each package, they will actually be in a single node_modules/ folder
  • Our packages only have binaries or specific versions in the individual node_modules/ folder
  • We are left with a single yarn.lock file

Among many other reasons, these are the ones that you will quickly understand in a moment. But now it's time to install a tool that will help us deal with running our packages in parallel as well as optimizing the build of our monorepo.

For this we will install turborepo as a development dependency of our workspace:



yarn add turbo -DW


Enter fullscreen mode Exit fullscreen mode

And now we add the turborepo configuration in a file called turbo.json with the following pipeline:



{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "dev": {
      "cache": false
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

As you may have noticed in the configuration above, we are not going to take advantage of the cache during the development environment because it makes more sense to use it only at build time (taking into account the example of the article).

With the turborepo configuration, we can now add some scripts to the package.json of the root of our workspace:



{
  "name": "@monorepo/root",
  "version": "1.0.0",
  "main": "index.js",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "license": "MIT",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build"
  },
  "devDependencies": {
    "turbo": "^1.3.1"
  }
}


Enter fullscreen mode Exit fullscreen mode

With our workspace created, the turborepo configured, and the scripts needed for today's article, we can proceed to the next step.

Api Package

First we have to create a packages/ folder that has been defined in our workspace:

First of all, in the root of our workspace, we have to create a packages/ folder that has been defined:



mkdir packages
cd packages


Enter fullscreen mode Exit fullscreen mode

Now inside the packages/ folder we can create each of our packages starting with the creation of our api. First let's create the folder:



mkdir api
cd api


Enter fullscreen mode Exit fullscreen mode

Then let's start the api package repository:



yarn init -y


Enter fullscreen mode Exit fullscreen mode

Now let's create the following tsconfig.json:



{
  "compilerOptions": {
    "target": "esnext",
    "module": "CommonJS",
    "allowJs": true,
    "removeComments": true,
    "resolveJsonModule": true,
    "typeRoots": ["./node_modules/@types"],
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "baseUrl": ".",
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "Node",
    "skipLibCheck": true,
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}


Enter fullscreen mode Exit fullscreen mode

And in our package.json we have to take into account the name of the package, which by convention is the name of the namespace, like this:



{
  "name": "@monorepo/api",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
}


Enter fullscreen mode Exit fullscreen mode

As you may have noticed, the name of the api package is @monorepo/api and we still have to take into account the main file of our package, however in today's article we only need to specify where the data types inferred by our router will be, in which case the main property of the package.json should look like this:



{
  "main": "src/router",
}


Enter fullscreen mode Exit fullscreen mode

Now, we can install the necessary dependencies:



yarn add fastify @fastify/cors @trpc/server zod
yarn add -D @types/node typescript ts-node-dev prisma


Enter fullscreen mode Exit fullscreen mode

Then initialize prisma setup:



npx prisma init


Enter fullscreen mode Exit fullscreen mode

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 Note {
  id        Int      @id @default(autoincrement())
  text      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}


Enter fullscreen mode Exit fullscreen mode

With the schema defined, you can run our first migration:



npx prisma migrate dev --name init


Enter fullscreen mode Exit fullscreen mode

Finally we can start building the api, starting with defining the tRPC context:



// @/packages/api/src/context/index.ts
import { inferAsyncReturnType } from "@trpc/server";
import { CreateFastifyContextOptions } from "@trpc/server/adapters/fastify";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const createContext = ({ req, res }: CreateFastifyContextOptions) => {
  return { req, res, prisma };
};

export type Context = inferAsyncReturnType<typeof createContext>;


Enter fullscreen mode Exit fullscreen mode

As you can see in the code above, our Prisma instance was created, in our context we can access the Fastify request and response object just as we can access the Prisma instance.

Now we can create the tRPC router of our api, creating only the following procedures:



// @/packages/api/src/router/index.ts
import * as trpc from "@trpc/server";
import { z } from "zod";

import type { Context } from "../context";

export const appRouter = trpc
  .router<Context>()
  .query("getNotes", {
    async resolve({ ctx }) {
      return await ctx.prisma.note.findMany();
    },
  })
  .mutation("createNote", {
    input: z.object({
      text: z.string().min(3).max(245),
    }),
    async resolve({ input, ctx }) {
      return await ctx.prisma.note.create({
        data: {
          text: input.text,
        },
      });
    },
  })
  .mutation("deleteNote", {
    input: z.object({
      id: z.number(),
    }),
    async resolve({ input, ctx }) {
      return await ctx.prisma.note.delete({
        where: {
          id: input.id,
        },
      });
    },
  });

export type AppRouter = typeof appRouter;


Enter fullscreen mode Exit fullscreen mode

With the router created, we can proceed to create the main file of our api:



// @/packages/api/src/main.ts
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
import cors from "@fastify/cors";

import { createContext } from "./context";
import { appRouter } from "./router";

const app = fastify({ maxParamLength: 5000 });

app.register(cors, { origin: "*" });

app.register(fastifyTRPCPlugin, {
  prefix: "/trpc",
  trpcOptions: { router: appRouter, createContext },
});

(async () => {
  try {
    await app.listen({ port: 5000 });
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
})();


Enter fullscreen mode Exit fullscreen mode

Again in the package.json of the api, we added the following scripts:



{
  "scripts": {
    "dev": "tsnd --respawn --transpile-only src/main.ts",
    "build": "tsc",
    "start": "node dist/main.js"
  },
}


Enter fullscreen mode Exit fullscreen mode

With our API configured, we can now move on to the creation and configuration of our web app.

Web App Package

Unlike what we did with the api, we are not going to do the configuration from absolute zero. Now, again inside the packages/ folder let's run the following command to boostrap a react application using vite:



yarn create vite web --template react-ts
cd web


Enter fullscreen mode Exit fullscreen mode

So, now inside the packages/ folder we have two folders (api/ and web/) that correspond to our api and our web app respectively.

Inside the folder of our web/ package, we will install the following dependencies:



yarn add @trpc/server zod @trpc/client @trpc/server @trpc/react react-query @nextui-org/react formik


Enter fullscreen mode Exit fullscreen mode

Next we will create our tRPC hook and we will import the router types from our api/ package:



// @/packages/web/src/hooks/trpc.ts
import { createReactQueryHooks } from "@trpc/react";
import type { AppRouter } from "@monorepo/api";

export const trpc = createReactQueryHooks<AppRouter>();


Enter fullscreen mode Exit fullscreen mode

Now in the main.tsx file we will add the UI library provider that we are going to use:



// @/packages/web/src/main.tsx
import ReactDOM from "react-dom/client";
import { NextUIProvider } from '@nextui-org/react';

import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <NextUIProvider>
    <App />
  </NextUIProvider>
);


Enter fullscreen mode Exit fullscreen mode

Now in the App.tsx file we can proceed to configure the tRPC provider and React Query:



// @/packages/web/src/App.tsx
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "react-query";

import { trpc } from "./hooks/trpc";
import AppBody from "./components/AppBody";

const App = () => {
  const queryClient = useMemo(() => new QueryClient(), []);
  const trpcClient = useMemo(
    () =>
      trpc.createClient({
        url: "http://localhost:5000/trpc",
      }),
    []
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <AppBody />
      </QueryClientProvider>
    </trpc.Provider>
  );
};

export default App;


Enter fullscreen mode Exit fullscreen mode

As you may have noticed, the <AppBody /> component hasn't been created yet and that's exactly what we're going to create now:



// @/packages/web/src/components/AppBody.tsx
import {
  Card,
  Text,
  Container,
  Textarea,
  Button,
  Grid,
} from "@nextui-org/react";
import { useCallback } from "react";
import { useFormik } from "formik";

import { trpc } from "../hooks/trpc";
interface IFormFields {
  content: string;
}

const AppBody = () => {
  const utils = trpc.useContext();
  const getNotes = trpc.useQuery(["getNotes"]);
  const createNote = trpc.useMutation(["createNote"]);
  const deleteNote = trpc.useMutation(["deleteNote"]);

  const formik = useFormik<IFormFields>({
    initialValues: {
      content: "",
    },
    onSubmit: async (values) => {
      await createNote.mutateAsync(
        {
          text: values.content,
        },
        {
          onSuccess: () => {
            utils.invalidateQueries(["getNotes"]);
            formik.resetForm();
          },
        }
      );
    },
  });

  const handleNoteRemoval = useCallback(async (id: number) => {
    await deleteNote.mutateAsync(
      {
        id,
      },
      {
        onSuccess: () => {
          utils.invalidateQueries(["getNotes"]);
        },
      }
    );
  }, []);

  return (
    <Container>
      <form
        onSubmit={formik.handleSubmit}
        style={{
          display: "flex",
          flexDirection: "row",
          justifyContent: "center",
          alignItems: "center",
          marginBottom: 50,
          marginTop: 50,
        }}
      >
        <Textarea
          underlined
          color="primary"
          labelPlaceholder="Type something..."
          name="content"
          value={formik.values.content}
          onChange={formik.handleChange}
          css={{ width: 350 }}
        />
        <Button
          shadow
          color="primary"
          auto
          css={{ marginLeft: 25 }}
          size="lg"
          type="submit"
        >
          Create
        </Button>
      </form>
      <Grid.Container gap={2}>
        {getNotes.data?.map((note) => (
          <Grid xs={4} key={note.id} onClick={() => handleNoteRemoval(note.id)}>
            <Card isHoverable variant="bordered" css={{ cursor: "pointer" }}>
              <Card.Body>
                <Text
                  h4
                  css={{
                    textGradient: "45deg, $blue600 -20%, $pink600 50%",
                  }}
                  weight="bold"
                >
                  {note.text}
                </Text>
              </Card.Body>
            </Card>
          </Grid>
        ))}
      </Grid.Container>
    </Container>
  );
};

export default AppBody;


Enter fullscreen mode Exit fullscreen mode

In the component above, we use the formik library to validate and manage the form of our component, which in this case has only one input. As soon as a note is created or deleted, we invalidate the getNotes query so that the UI is always up to date.

How to run

If you want to initialize the development environment, in order to work on packages, run the following command in the project root:



yarn dev


Enter fullscreen mode Exit fullscreen mode

If you want to build packages, run the following command in the project root:



yarn build

Enter fullscreen mode Exit fullscreen mode




Conclusion

As always, I hope you enjoyed this article and that it was useful to you. If you have seen any errors in the article, please let me know in the comments so that I can correct them.

Before I finish, I will share with you this link to the github repository with the project code for this article.

bye bye gif

Top comments (3)

Collapse
 
valkedev profile image
Anatoly Sokolov

You're mistaken a bit: not yarn add turborepo -DW but yarn add turbo -DW

Collapse
 
franciscomendes10866 profile image
Francisco Mendes

Thank you very much Anatoly for your contribution 🙏 I already edited 😁

Collapse
 
exponent42 profile image
exponent42 • Edited

thanks for this. how would you handle within a Next project? just curious if a separate API inside a monorepo/api would invalidate performance benefits of having it within next/pages/api.