
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:
npx create-next-app@latest prisma-monorepo
cd prisma-monorepoYou will be asked a few questions. Select the following options:
- TypeScript
- ESLint
- Tailwind CSS
- No
srcdirectory - App Router
- Turbopack
- No customized import alias
Configure Prisma
Install Prisma and the Postgres client:
npm install prisma tsx --save-dev
npm install @prisma/extension-accelerate @prisma/clientYou 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:
npx prisma init --db --output app/generated/prismaThis automatically generates:
- A
prismadirectory, with aschema.prismafile that setsapp/generated/prismaas the output path for the Prisma client. - A Prisma Postgres database connection string in the
.envfile.
Add the following tables to the prisma/schema.prisma file:
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:
npx prisma migrate dev --name initThis 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:
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:
"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:
npx prisma db seedRunning 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.
mkdir -p lib
touch lib/prisma.tsIn the lib/prisma.ts file, add the following code to create a Prisma client instance:
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:
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.
mkdir -p app/orders
touch app/orders/page.tsxIn the app/orders/page.tsx file, add the following code to create a simple order management interface:
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.
mkdir -p app/orders/[id]
touch app/orders/[id]/page.tsxIn the app/orders/[id]/page.tsx file, add the following code to display the details of a specific order:
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.
mkdir -p app/orders/new
touch app/orders/new/page.tsxIn the app/orders/new/page.tsx file, add the following code to create a new order:
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?
- Subscribe to our JavaScript Sorcery newsletter and never miss an article again.
- Start monitoring your JavaScript app with AppSignal.
- Share this article on social media

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 ReyesBecome our next author!
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!


