javascript

Manage a Next.js Monorepo with Prisma

Camilo Reyes

Camilo Reyes on

Manage a Next.js Monorepo with Prisma

Prisma is a modern open-source database toolkit designed to simplify data workflows for developers. It provides a powerful and intuitive way to interact with databases, and it is type-safe.

Prisma works well in a monorepo because it can be used in both the frontend and backend of a full-stack application. This enables developers to share types and logic across the entire codebase, resulting in a more efficient development process.

A monorepo can be written in a single unified language, such as TypeScript, with a single ORM like Prisma to manage the database. This allows for a more streamlined development experience, as the same code can be reused throughout your entire application.

We will use Next.js as the framework for our monorepo. Next.js is a powerful React framework that allows for server-side rendering and code sharing between the frontend and backend.

We'll build a pizza ordering app, using Prisma as the ORM on a Postgres database, all within a unified TypeScript stack in a monorepo.

Set Up Your Next.js Project

Our app will consist of a Next.js frontend that allows users to order pizzas, list their previous orders, and cancel orders. This is a typical CRUD (Create, Read, Update, Delete) application that fits perfectly in this monorepo mindset.

To fire up your Next.js monorepo with Prisma, you can use the following commands:

Shell
npx create-next-app@latest prisma-monorepo cd prisma-monorepo

You will be asked a few questions. Select the following options:

  • TypeScript
  • ESLint
  • Tailwind CSS
  • No src directory
  • App Router
  • Turbopack
  • No customized import alias

Configure Prisma

Install Prisma and the Postgres client:

Shell
npm install prisma tsx --save-dev npm install @prisma/extension-accelerate @prisma/client

You will need to authenticate use of the Postgres database with a Prisma account. Simply visit the Prisma Console and create a new project. We recommend using your GitHub account to sign in and pick the free tier.

Once authenticated, initialize Prisma in your project:

Shell
npx prisma init --db --output app/generated/prisma

This automatically generates:

  • A prisma directory, with a schema.prisma file that sets app/generated/prisma as the output path for the Prisma client.
  • A Prisma Postgres database connection string in the .env file.

Add the following tables to the prisma/schema.prisma file:

prisma
model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt email String @unique name String? orders Order[] } model Order { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt status String @default("PENDING") user User @relation(fields: [userId], references: [id]) userId Int items OrderItem[] } model OrderItem { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt quantity Int @default(1) order Order @relation(fields: [orderId], references: [id]) orderId Int pizza Pizza @relation(fields: [pizzaId], references: [id]) pizzaId Int } model Pizza { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt name String @unique price Float orderItems OrderItem[] }

Now run the migration:

Shell
npx prisma migrate dev --name init

This creates four models in the database (User, Order, OrderItem, and Pizza), allowing us to manage users, orders, and pizzas in our application and to create relationships between them.

Add seed data to populate the database with some initial pizzas and orders. Create a prisma/seed.ts file with the following code:

typescript
import { PrismaClient, Prisma } from "./app/generated/prisma"; const prisma = new PrismaClient(); const userData: Prisma.UserCreateInput[] = [ { email: "xyz@abc.io", name: "Clark Kent", updatedAt: new Date(), orders: { create: [ { updatedAt: new Date(), items: { create: [ { quantity: 2, updatedAt: new Date(), pizza: { create: { name: "Pepperoni", price: 12.99, updatedAt: new Date(), }, }, }, { quantity: 1, updatedAt: new Date(), pizza: { create: { name: "Cheese", price: 10.99, updatedAt: new Date(), }, }, }, ], }, }, ], }, }, ]; async function main() { console.log("Start seeding ..."); for (const u of userData) { const user = await prisma.user.create({ data: u, }); console.log(`Created user with id: ${user.id}`); } console.log("Seeding finished."); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });

Change the package.json to include a script to run the seed:

JSON
"prisma": { "seed": "tsx prisma/seed.ts" }

Note: If you encounter issues with Turborepo, they have been resolved in the latest versions. Ensure you are using the latest version of Prisma and Turborepo.

Run the seed script to populate the database:

Shell
npx prisma db seed

Running the seed script a second time will not create duplicate entries because SQL unique constraints will prevent that.

If you reformat the content of the SQL migration file, Prisma will fail with an error. This is because it uses a SHA-256 hash of the migration file to track changes. You can update the _prisma_migrations table to update the hash, as long as the schema remains the same. However, it is generally not recommended that you modify migration files after they have been applied, as this can lead to inconsistencies in your database.

Set Up the Prisma Database Client

The Prisma client will connect to the database and perform queries.

Create a new lib directory in your project's root and add a prisma.ts file.

Shell
mkdir -p lib touch lib/prisma.ts

In the lib/prisma.ts file, add the following code to create a Prisma client instance:

typescript
import { PrismaClient } from "../prisma/app/generated/prisma"; import { withAccelerate } from "@prisma/extension-accelerate"; const globalForPrisma = global as unknown as { prisma: PrismaClient; }; const prisma = globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate()); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; export default prisma;

This instantiates a Prisma client and attaches it to the global object to avoid creating multiple instances during hot reloading in development mode. The withAccelerate extension improves performance.

Query Your Database

Open the app/page.tsx file and replace its content with the following code to query the database and display the users and pizzas:

typescript
import prisma from "@/lib/prisma"; export default async function Home() { const users = await prisma.user.findMany(); const pizzas = await prisma.pizza.findMany(); return ( <div className="p-8"> <h1 className="text-2xl font-bold mb-4">Users</h1> <ul className="space-y-4"> {users.map((user) => ( <li key={user.id} className="border p-4 rounded"> <h2 className="text-xl font-semibold mb-2">{user.name}</h2> <p>Email: {user.email}</p> </li> ))} </ul> <h2 className="text-2xl font-bold mt-8 mb-4">Pizzas</h2> <ul className="space-y-4"> {pizzas.map((pizza) => ( <li key={pizza.id} className="border p-4 rounded"> <h3 className="text-xl font-semibold mb-2">{pizza.name}</h3> <p>Price: ${pizza.price.toFixed(2)}</p> </li> ))} </ul> </div> ); }

You can see a list of users and pizzas at http://localhost:3000/.

The method prisma.user.findMany() retrieves all users from the database, and prisma.pizza.findMany() retrieves all pizzas. The results are then displayed in a simple list format.

This technique allows you to query the database directly from the Next.js component, leveraging the power of Prisma to interact with the database in a type-safe manner.

In this monorepo, you eliminate the need for a separate backend server because Next.js can handle both the frontend and backend logic. This means fewer layers to manage, and a more streamlined development experience in a unified programming language.

Manage Your Orders

As this is a typical CRUD application, we can create a new app/orders/page.tsx page to manage orders.

Shell
mkdir -p app/orders touch app/orders/page.tsx

In the app/orders/page.tsx file, add the following code to create a simple order management interface:

typescript
import prisma from "@/lib/prisma"; import Link from "next/link"; export default async function OrdersPage() { const orders = await prisma.order.findMany({ where: { status: { not: "CANCELLED", }, }, include: { items: { include: { pizza: true, }, }, }, }); return ( <div className="p-8"> <h1 className="text-2xl font-bold mb-4">Orders</h1> <ul className="space-y-4"> {orders.map((order) => ( <li key={order.id} className="border p-4 rounded"> <h2 className="text-xl font-semibold mb-2">Order #{order.id}</h2> <ul className="mt-2 space-y-2"> {order.items.map((item) => ( <li key={item.id} className="flex justify-between"> <span> {item.quantity} x {item.pizza.name} </span> <span>${item.pizza.price.toFixed(2)}</span> </li> ))} </ul> <p className="mt-2 font-semibold"> Total: $ {order.items .reduce( (total, item) => total + item.quantity * item.pizza.price, 0 ) .toFixed(2)} </p> <Link href={`/orders/${order.id}`} className="mt-4 inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" > View Order </Link> </li> ))} </ul> </div> ); }

You can see a list of orders at http://localhost:3000/orders.

This code retrieves all orders from the database that are not cancelled, and displays them in a list format. Interactive links are provided to view each order in detail via the Link component from Next.js.

Next, create a dynamic route to view each order via a new app/orders/[id]/page.tsx file.

Shell
mkdir -p app/orders/[id] touch app/orders/[id]/page.tsx

In the app/orders/[id]/page.tsx file, add the following code to display the details of a specific order:

typescript
import Form from "next/form"; import prisma from "@/lib/prisma"; import { notFound } from "next/navigation"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; export default async function OrderPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const orderId = parseInt(id, 10); const order = await prisma.order.findUnique({ where: { id: orderId }, include: { user: true, items: { include: { pizza: true, }, }, }, }); if (!order) { notFound(); } const cancelOrder = async () => { "use server"; await prisma.order.update({ where: { id: order.id }, data: { status: "CANCELLED", updatedAt: new Date() }, }); revalidatePath("/orders/" + order.id); redirect("/orders/" + order.id); }; return ( <div className="p-8"> <h1 className="text-2xl font-bold mb-4">Order #{order.id}</h1> <p>Ordered By: {order.user.name}</p> <p>Updated at: {new Date(order.updatedAt).toLocaleString()}</p> <p>Status: {order.status}</p> <ul className="mt-2 space-y-2"> {order.items.map((item) => ( <li key={item.id} className="flex justify-between"> <span> {item.quantity} x {item.pizza.name} </span> <span>${item.pizza.price.toFixed(2)}</span> </li> ))} </ul> <p className="mt-2 font-semibold"> Total: $ {order.items .reduce((total, item) => total + item.quantity * item.pizza.price, 0) .toFixed(2)} </p> {order.status !== "CANCELLED" && ( <Form action={cancelOrder}> <button type="submit" className="mt-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" > Cancel Order </button> </Form> )} </div> ); }

You can view a particular order with http://localhost:3000/orders/1, where 1 is the order ID.

The use server directive allows you to define server-side actions that can be triggered from the client side via a form submission. The params object contains dynamic route parameters — in this case, the order ID. The notFound function handles cases where an order does not exist, and the revalidatePath and redirect functions update the page and bust the cache after cancelling an order.

To create a new order, you can add a form to the app/orders/new/page.tsx file.

Shell
mkdir -p app/orders/new touch app/orders/new/page.tsx

In the app/orders/new/page.tsx file, add the following code to create a new order:

typescript
import Form from "next/form"; import prisma from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; export default async function NewOrderPage() { const pizzas = await prisma.pizza.findMany(); const createOrder = async (formData: FormData) => { "use server"; const items = formData.getAll("items") as string[]; const quantities = formData.getAll("quantities") as string[]; const orderItems = items.map((item, index) => ({ pizzaId: parseInt(item, 10), quantity: parseInt(quantities[index], 10), updatedAt: new Date(), })); await prisma.order.create({ data: { userId: 1, items: { create: orderItems, }, updatedAt: new Date(), }, }); revalidatePath("/orders"); redirect("/orders"); }; return ( <div className="p-8"> <h1 className="text-2xl font-bold mb-4">Create New Order</h1> <Form action={createOrder} className="space-y-4"> {pizzas.map((pizza) => ( <div key={pizza.id} className="flex items-center space-x-4"> <input type="checkbox" name="items" value={pizza.id} className="h-5 w-5" /> <label className="flex-1"> {pizza.name} - ${pizza.price.toFixed(2)} </label> <input type="number" name="quantities" defaultValue={1} min={1} max={5} className="w-16 border rounded px-2 py-1" /> </div> ))} <button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" > Submit Order </button> </Form> </div> ); }

To create a new order, navigate to http://localhost:3000/orders/new.

This code creates a form that allows users to select and specify quantities of pizzas. When the form is submitted, a new order is created in the database with the selected pizzas and quantities. The revalidatePath and redirect functions update the page and bust the cache after creating a new order.

And that's it!

Final Thoughts

For a typical CRUD application, a monorepo setup with a unified stack using Next.js and Prisma provides a powerful and efficient way to manage your data. You can easily create, read, update, and delete records in your Postgres database, all while sharing types and logic across the frontend and backend.

The results are simple but powerful. You can deliver with high velocity and low complexity.

Happy coding!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Camilo Reyes

Camilo Reyes

Our guest author Camilo is a Software Engineer from Houston, Texas. He’s passionate about JavaScript and clean code that runs without drama. When not coding, he loves to cook and work on random home projects.

All articles by Camilo Reyes

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps