pnpm workspaces
Set up Prisma 8 in a shared database package inside a pnpm workspaces monorepo and query it from a Next.js app.
Introduction
This guide shows you how to set up Prisma 8 in its own package inside a pnpm workspaces monorepo. The database package owns the contract, the emitted types, and the client. A Next.js app in the same workspace imports that client and renders users from the database.
Every command and output below was run end to end with pnpm 12 against a PostgreSQL database.
Prisma 8 is the current release of Prisma ORM. Prisma 7 remains fully supported; the Prisma 7 version of this guide is at /guides/v7/deployment/pnpm-workspaces.
Prerequisites
- Node.js 24 or later
- pnpm 10 or later (this guide uses pnpm 12)
- A PostgreSQL connection string, or nothing at all:
npx create-db@latestcan create a Prisma Postgres database for you
Use with your agent
To delegate this guide to your coding agent, copy the prompt below and hand it over:
1. Create the workspace
Create the monorepo directory and initialize it:
mkdir my-monorepo
cd my-monorepo
pnpm initpnpm 12 writes a root package.json with "type": "module" and a pinned packageManager field. Next, create pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
allowBuilds:
esbuild: true
msgpackr-extract: true
workerd: trueThe allowBuilds block matters. pnpm does not run dependency install scripts unless you approve them, and pnpm 11 and later turn an unapproved script into an install error. The Prisma 8 CLI's toolchain pulls in three packages with install scripts, so approve them up front. The same key works on pnpm 10.26 and later; without it you get ERR_PNPM_IGNORED_BUILDS the first time orm init runs pnpm add.
Create the directories for apps and shared packages:
mkdir -p apps packages/database2. Set up the shared database package
The database package holds the contract (your schema), the emitted contract.json and contract.d.ts, and the db client every app imports. Prisma 8 has no prisma generate step and no generated client directory: contract emit writes the two artifacts next to the contract, and the runtime reads them.
2.1. Initialize Prisma 8 in the package
cd packages/database
pnpm init
npx prisma@latest orm init --target postgresAnswer the prompts: choose PSL for the authoring style and keep the default schema path, src/prisma/contract.prisma. orm init detects pnpm from the workspace, adds the dependencies to this package, and writes the Prisma 8 files:
▸ pnpm add @prisma/orm-postgres dotenv
✔ pnpm add @prisma/orm-postgres dotenv
▸ pnpm add -D prisma@latest @types/node
✔ pnpm add -D prisma@latest @types/node
▸ pnpm add -D @prisma/cli-engine
✔ pnpm add -D @prisma/cli-engine
▸ Emit the contract
✘ Emit the contract
│ target: postgres
│ authoring: psl
│ schema: src/prisma/contract.prisma
written
├─ src/prisma/contract.prisma
├─ prisma.config.ts
├─ src/prisma/db.ts
├─ prisma-8.md
├─ .env.example
├─ tsconfig.json
├─ .gitignore
├─ .gitattributes
└─ package.json
✘ [CLI.INIT_EMIT_FAILED] Failed to emit contractThe scaffold is complete, but the final emit step fails in a pnpm workspace: pnpm links the @prisma/cli-engine that orm init adds last to a directory that does not exist, so the CLI cannot evaluate prisma.config.ts. Re-add both packages; this repairs the link:
pnpm add -D prisma@latest @prisma/cli-engine@latestpnpm replaces both dev dependencies and relinks them.
Now emit the contract with the package's own CLI:
pnpm prisma contract emit▸ Resolving contract source...
✔ Resolving contract source...
▸ Emitting contract...
✔ Emitting contract...
│ contract: src/prisma/contract.json
│ types: src/prisma/contract.d.ts
✔ Emitted contract.json and contract.d.tsorm init wrote a starter contract with User and Post models in src/prisma/contract.prisma, and a client in src/prisma/db.ts:
import 'dotenv/config';
import postgres from '@prisma/orm-postgres/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL']!,
});There is no driver adapter and no engine to configure. The with { type: 'json' } import attribute is required by Node's ESM loader; pnpm's pnpm init already set "type": "module" on the package, which is what the generated files expect. If your package declares "type": "commonjs", orm init leaves it alone and prints a warning; change it to "module".
2.2. Connect the database
prisma.config.ts loads .env through dotenv/config and reads DATABASE_URL. Create .env in the package with your connection string, or the one npx create-db@latest prints:
DATABASE_URL="postgres://user:password@localhost:5432/mydb"Apply the contract to the database and sign it:
pnpm prisma db init✔ Initialising database across spaces
│ contract: src/prisma/contract.json
│ database: postgres://****@localhost:5432/mydb
✔ Applied 5 operation(s) across 1 contract space
App space
├─ Create table "post"
├─ Create table "user"
├─ Add unique constraint on "user" (email)
├─ Create index "post_authorId_idx_e47547ed" on "post"
├─ Add foreign key "post_authorId_fkey" on "post"
└─ marker 1e8412e162dbbe69f4bb3bf8d07f0280ae67eaab15c34dcf201e67468315428d
✔ Advanced ref "db" → 1e8412e162dbbe69f4bb3bf8d07f0280ae67eaab15c34dcf201e67468315428dIf db init stops with Connection terminated unexpectedly, a database you just created is still starting; wait a few seconds and run it again. The command is safe to repeat and reports Database already matches contract when there is nothing left to do.
2.3. Export the client and add a seed script
Create the package entry point that apps will import:
export { db } from "./prisma/db";Add a seed script so the app has rows to render. Node.js 24 runs TypeScript directly, but it needs the .ts extension on relative imports, so this file names it:
import { db } from "./prisma/db.ts";
const users = [
{ email: "alice@prisma.io", name: "Alice" },
{ email: "bob@prisma.io", name: "Bob" },
];
for (const user of users) {
await db.orm.public.User.upsert({
create: user,
update: {},
conflictOn: { email: user.email },
});
}
console.log(await db.orm.public.User.select("id", "email", "name").all());
await db.runtime().close();Point the package at the entry point and add scripts for the database steps. Replace the main field pnpm init wrote with an exports map, and drop the placeholder test script:
{
"name": "database",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"contract:emit": "prisma contract emit",
"db:init": "prisma db init",
"db:update": "prisma db update",
"seed": "node src/seed.ts"
}
}Keep the dependencies and devDependencies that orm init added. Run the seed:
pnpm seed$ node src/seed.ts
[
{ id: 1, email: 'alice@prisma.io', name: 'Alice' },
{ id: 2, email: 'bob@prisma.io', name: 'Bob' }
]The script resolves @prisma/orm-postgres through pnpm's isolated node_modules without any hoisting configuration, because the package declares it as a direct dependency. That is the rule to keep in mind for the rest of the workspace: the database package depends on @prisma/orm-postgres, and apps depend on database.
3. Set up the Next.js app
3.1. Scaffold the app into the workspace
cd ../../apps
pnpm create next-app@latest web --yes --skip-install--yes accepts the defaults (App Router, TypeScript, Tailwind CSS, no src/ directory). --skip-install keeps create-next-app from installing into a nested node_modules; the workspace root installs for every package. The scaffold still writes two files that belong to the root, so remove them:
rm -rf web/.git web/pnpm-workspace.yamlAdd the shared package as a dependency of the app:
"dependencies": {
"database": "workspace:*",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
}Next.js reads .env from the app directory, and db.ts reads it from the working directory of the dev server, which is the same place. Copy the file from the database package:
cp ../packages/database/.env web/.envInstall from the workspace root so pnpm links database into the app:
cd ..
pnpm installScope: all 3 workspace projects
Progress: resolved 8, reused 454, downloaded 0, added 8, done
Done in 5.8s using pnpm v12.3.43.2. Render users from the shared package
Replace apps/web/app/page.tsx with a server component that queries through the shared client:
import { db } from "database";
export const dynamic = "force-dynamic";
export default async function Home() {
const users = await db.orm.public.User.select("id", "email", "name").all();
return (
<main className="p-8">
<h1 className="text-2xl font-semibold">Users</h1>
{users.length === 0 ? (
<p>No users in the database yet.</p>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name ?? "Anonymous"} ({user.email})
</li>
))}
</ul>
)}
</main>
);
}Model access is namespace-qualified on PostgreSQL: db.orm.public.User. force-dynamic makes Next.js query on each request instead of at build time, so pnpm build does not need a reachable database. Next.js compiles the package's TypeScript source through the exports map; no transpilePackages entry is needed.
3.3. Add root scripts
Add scripts to the root package.json that run each step in the right package. build re-emits the contract before the app builds, so the types the app compiles against always match contract.prisma:
"scripts": {
"dev": "pnpm --filter web dev",
"build": "pnpm --filter database contract:emit && pnpm --filter web build",
"start": "pnpm --filter web start",
"db:init": "pnpm --filter database db:init",
"db:update": "pnpm --filter database db:update",
"seed": "pnpm --filter database seed"
}3.4. Run the app
From the workspace root:
pnpm dev$ next dev
▲ Next.js 16.3.4 (Turbopack)
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 479msOpen http://localhost (set PORT to change it). The page lists Alice and Bob, rendered by a server component calling Prisma 8 through the database package.
4. Build for production
pnpm build$ prisma contract emit
$ next build
▲ Next.js 16.3.4 (Turbopack)
✓ Compiled successfully in 1026ms
Running TypeScript ...
Finished TypeScript in 2.2s ...
✓ Generating static pages using 5 workers (3/3) in 519ms
Route (app)
┌ ƒ /
└ ○ /_not-foundThe type check runs across the package boundary: next build type-checks packages/database/src/index.ts along with the app. Then serve the build:
pnpm startThe page renders the same users from the production server.
5. (Optional) Browse your data in Prisma Studio
Prisma Studio ships with the Prisma 7 CLI and connects to a Prisma 8 database through --url. Run it from the workspace root, which has no prisma.config.ts for the Prisma 7 CLI to trip over:
pnpm dlx prisma@7.10.0 studio --url "postgres://user:password@localhost:5432/mydb"Prisma Studio is running at: http://localhost:51212Open the URL Studio prints. The user and post tables appear under Tables, with the seeded rows ready to edit. See Studio with Prisma 8 for the migration history view.
Common gotchas
pnpm dlx prisma@latest stops at an interactive Choose which packages to build prompt, because dlx runs outside the workspace and ignores its allowBuilds. Either answer the prompt, or run npx prisma@latest from a package directory as this guide does. At the workspace root, npx itself fails with EBADDEVENGINES: pnpm 12's pnpm init writes a devEngines.packageManager field that npm enforces. Use the package's own CLI (pnpm prisma ...) or pnpm dlx there.
- If
pnpm prisma contract emitreportsCLI.CONFIG_UNREADABLEwithCannot find module '@prisma/cli-engine'orNo "exports" main defined, the@prisma/cli-enginelink inpackages/database/node_modulesis dangling.pnpm install --forcedoes not repair it;pnpm add -D prisma@latest @prisma/cli-engine@latestdoes. - Every Prisma command reads
DATABASE_URLfrompackages/database/.envthroughprisma.config.ts, and the app readsapps/web/.env. Keep the two files in sync, or export the variable in your shell and drop both files. - Do not call
db.runtime().close()in a page or route handler. The client is a module-level singleton whose connection pool is shared across requests; close it only in scripts that exit, likeseed.ts. - After you change
src/prisma/contract.prisma, runpnpm --filter database contract:emitso the app sees the new types, thenpnpm db:updateto apply the change. The rootbuildscript emits for you before every build.
Prompt your coding agent
Run pnpm prisma init once inside packages/database to install the Prisma 8 skills for your coding agent. It adds a postinstall script that keeps the skills matching your installed packages, and it installs them into .claude/skills, .cursor/skills, .agents/skills, and .devin/skills under the package. Prompts that map to this guide:
- "Using the prisma-8 skill, add a
Postlist under each user on the home page with.include('posts')." - "Add a
packages/databasescript that creates a post for a given user email." - "Add a
roleenum to theUsermodel incontract.prisma, emit the contract, and update the database."
Next steps
You now have a pnpm workspace where one package owns the Prisma 8 contract and client, and a Next.js app that renders through it.
- To add task orchestration and caching on top of this setup, see the Turborepo guide.
- Learn the fundamentals: filtering, sorting, pagination, and writes.
- Read the Prisma 8 overview for the concepts behind contracts and typed queries.
- Use migration plan and db migrate when you want checked-in migrations instead of
db update.
Turborepo
Share one Prisma 8 database package across the apps in a Turborepo monorepo, with contract emit and migrations wired into turbo tasks.
Bun workspaces
Set up Prisma 8 in a Bun workspaces monorepo through a shared database package, seed it with Bun, and query it from a Next.js app in the same workspace.
