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
Then we initialize the repository:
yarn init -y
And in our package.json
we added the following properties:
{
"private": true,
"workspaces": [
"packages/*"
],
}
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
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/**"]
}
}
}
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"
}
}
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
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
Then let's start the api package repository:
yarn init -y
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"]
}
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",
}
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",
}
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
Then initialize prisma setup:
npx prisma init
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
}
With the schema defined, you can run our first migration:
npx prisma migrate dev --name init
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>;
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;
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);
}
})();
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"
},
}
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
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
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>();
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>
);
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;
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;
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
If you want to build packages, run the following command in the project root:
yarn build
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.
Top comments (3)
You're mistaken a bit: not
yarn add turborepo -DW
butyarn add turbo -DW
Thank you very much Anatoly for your contribution 🙏 I already 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.