Skip to main content
Prisma Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

Turborepo

This guide shows you how to set up Prisma 8 as a standalone package in a Turborepo monorepo, so that every app shares one database client, one contract, and one migration workflow. You scaffold a monorepo, add a packages/database package that owns the Prisma 8 setup, wire its tasks into turbo.json, and render users from the web Next.js app.

Every command and output below was run end to end against a local PostgreSQL database.

  • Node.js 24 or later
  • pnpm (this guide uses pnpm; the Turborepo scaffold also supports npm, yarn, and Bun)
  • A PostgreSQL connection string, or nothing at all: npx create-db@latest can create a Prisma Postgres database for you

To delegate this guide to your coding agent, copy the prompt below and hand it over:

with
Set up Prisma 8 as a shared database package in a new Turborepo monorepo and render users from the web app.

1. Scaffold: `npx create-turbo@latest turborepo-prisma` with pnpm. Then run `npx prisma@latest init` at the monorepo root so the Prisma agent skills are installed and stay current, and use them.

2. Create `packages/database` with `{ "name": "@repo/db", "version": "0.0.0", "private": true }` as its package.json. Because pnpm refuses to run dependency build scripts until they are allowed, add `allowBuilds: { esbuild: true, msgpackr-extract: true, workerd: true }` to `pnpm-workspace.yaml` first. Then run `npx prisma@latest orm init --yes --target postgres --authoring psl` inside `packages/database`, and move the package to the current CLI with `pnpm add -D prisma@latest --filter @repo/db`.

3. Get a database connection string: use the one I give you, or create a Prisma Postgres database with `npx create-db@latest` and show me the claim URL it prints. Write it as `DATABASE_URL` into `packages/database/.env`.

4. In `packages/database/package.json`, add `"exports": { ".": "./src/index.ts" }` and the scripts `contract:emit` (`prisma contract emit`), `migration:plan` (`prisma migration plan`), `db:migrate` (`prisma db migrate --advance-ref db`), `migration:status` (`prisma migration status`), and `check-types` (`tsc --noEmit`). Create `src/index.ts` that re-exports `db` from `./prisma/db` and the `Contract` type from `./prisma/contract.d`.

5. In `turbo.json`, add `"globalEnv": ["DATABASE_URL"]`, a cached `contract:emit` task with inputs `src/prisma/contract.prisma` and `prisma.config.ts` and outputs `src/prisma/contract.json` and `src/prisma/contract.d.ts`, make `build`, `dev`, and `check-types` depend on `^contract:emit`, add `migration:plan` (dependsOn `contract:emit`, cache false) and `db:migrate` (cache false).

6. Run `pnpm turbo run migration:plan --filter=@repo/db -- --name init`, review the DDL preview, then `pnpm turbo run db:migrate --filter=@repo/db`.

7. Add `"@repo/db": "workspace:*"` to `apps/web/package.json`, run `pnpm install`, copy `packages/database/.env` to `apps/web/.env`, and replace `apps/web/app/page.tsx` with a server component that exports `const dynamic = "force-dynamic"` and renders `await db.orm.public.User.select("id", "email", "name").all()` as a list, following https://www.prisma.io/docs/guides/deployment/turborepo.md.

8. Start `pnpm turbo run dev --filter=web` in the background, wait until it reports ready, verify http://localhost:3000 renders, then stop the dev server.

Create a Turborepo monorepo named turborepo-prisma:

bunx create-turbo@latest turborepo-prisma
Bash
pnpm dlx create-turbo@latest turborepo-prisma
Bash
yarn dlx create-turbo@latest turborepo-prisma
Bash
npx create-turbo@latest turborepo-prisma

When asked which package manager to use, pick pnpm. The scaffold creates two Next.js apps and three shared packages, then installs dependencies:

no-copy
>>> Creating a new Turborepo with:

Application packages
 - apps/docs
 - apps/web
Library packages
 - packages/eslint-config
 - packages/typescript-config
 - packages/ui

>>> Success! Created your Turborepo at turborepo-prisma

Move into the project root:

Bash
cd turborepo-prisma

When asked which package manager to use, pick pnpm. The scaffold creates two Next.js apps and three shared packages, then installs dependencies:

>>> Creating a new Turborepo with:

Application packages

 - apps/docs

 - apps/web

Library packages

 - packages/eslint-config

 - packages/typescript-config

 - packages/ui

>>> Success! Created your Turborepo at turborepo-prisma

Move into the project root:

cd turborepo-prisma

Create a database directory inside packages with a minimal package.json:

mkdir -p packages/database
title="packages/database/package.json"
{

  "name": "@repo/db",

  "version": "0.0.0",

  "private": true

}

pnpm does not run dependency build scripts until you allow them. Since pnpm 11 it fails the install when it finds scripts it was not told about; pnpm 10 only warns and skips them unless you set strictDepBuilds: true. The allowBuilds key exists since pnpm 10.26, so use that version or later. Prisma ORM 8's toolchain ships three packages with build scripts, so declare them in pnpm-workspace.yaml before you install anything:

title="pnpm-workspace.yaml"
packages:

  - "apps/*"

  - "packages/*"

allowBuilds: 

  esbuild: true

  msgpackr-extract: true

  workerd: true

Skip this step on npm, yarn, or Bun. If you forget it, orm init in the next step stops with CLI.INIT_INSTALL_FAILED; run pnpm approve-builds, then re-run the pnpm add commands it printed and pnpm contract:emit.

Run orm init inside the package. It is the existing-project path: it adds Prisma 8 to the package you just created instead of scaffolding a new app.

cd packages/database
bunx prisma@latest orm init --yes --target postgres --authoring psl
Bash
pnpm dlx prisma@latest orm init --yes --target postgres --authoring psl
Bash
yarn dlx prisma@latest orm init --yes --target postgres --authoring psl
Bash
npx prisma@latest orm init --yes --target postgres --authoring psl

--yes accepts the defaults for the remaining prompts (the Prisma schema language for the contract, and src/prisma/contract.prisma as its path); drop it to answer them yourself.

no-copy
✔ pnpm add @prisma/orm-postgres dotenv
✔ pnpm add -D prisma@latest @types/node
✔ pnpm add -D @prisma/cli-engine
✔ 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

✔ Done. Open prisma-8.md to get started.

orm init detects pnpm from the workspace, adds the packages to @repo/db, sets "type": "module", and writes four files that matter for the rest of this guide:

  • src/prisma/contract.prisma: your schema. It starts with User and Post models.
  • src/prisma/contract.json and src/prisma/contract.d.ts: the emitted contract the runtime and the CLI read. They replace Prisma 7's generated client: there is no prisma generate step and no generated directory to ignore. Commit both files; .gitattributes already marks them as generated so they collapse in diffs.
  • src/prisma/db.ts: the client instance, typed by the contract.
  • prisma.config.ts: where the CLI finds the contract and the database connection.

The generated db.ts reads DATABASE_URL from the environment and is the module every app will import:

packages/database/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']!,
});

--yes accepts the defaults for the remaining prompts (the Prisma schema language for the contract, and src/prisma/contract.prisma as its path); drop it to answer them yourself.

✔ pnpm add @prisma/orm-postgres dotenv

✔ pnpm add -D prisma@latest @types/node

✔ pnpm add -D @prisma/cli-engine

✔ 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

✔ Done. Open prisma-8.md to get started.

orm init detects pnpm from the workspace, adds the packages to @repo/db, sets "type": "module", and writes four files that matter for the rest of this guide:

  • src/prisma/contract.prisma: your schema. It starts with User and Post models.
  • src/prisma/contract.json and src/prisma/contract.d.ts: the emitted contract the runtime and the CLI read. They replace Prisma 7's generated client: there is no prisma generate step and no generated directory to ignore. Commit both files; .gitattributes already marks them as generated so they collapse in diffs.
  • src/prisma/db.ts: the client instance, typed by the contract.
  • prisma.config.ts: where the CLI finds the contract and the database connection.

The generated db.ts reads DATABASE_URL from the environment and is the module every app will import:

title="packages/database/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']!,

});

Create packages/database/.env with your PostgreSQL connection string. Use your own, or create a Prisma Postgres database with npx create-db@latest; it prints a connection string and a claim URL you can open to keep the database.

title="packages/database/.env"
DATABASE_URL="postgres://user:password@localhost:5432/turborepo_prisma"

prisma.config.ts and db.ts both load this file through dotenv/config, so every CLI command in this package and every query at runtime picks it up. The root .gitignore from the scaffold already ignores .env files.

Open src/prisma/contract.prisma. The starter contract already has the two models this guide renders, so leave it as it is for now; you will change it in step 7.

title="packages/database/src/prisma/contract.prisma"
// use prisma-8

model User {

  id        Int      @id @default(autoincrement())

  email     String   @unique

  username  String?

  name      String?

  posts     Post[]

  createdAt TimestamptzString @default(now())

  updatedAt temporal.updatedAtString()

}

model Post {

  id        Int      @id @default(autoincrement())

  title     String

  content   String?

  author    User     @relation(fields: [authorId], references: [id])

  authorId  Int

  createdAt TimestamptzString @default(now())

  updatedAt temporal.updatedAtString()

}

Add the database scripts and a package entrypoint to packages/database/package.json. orm init already added contract:emit; the rest map to the Prisma 8 migration loop:

title="packages/database/package.json"
{

  "name": "@repo/db",

  "type": "module",

  "version": "0.0.0",

  "private": true,

  "exports": { 

    ".": "./src/index.ts"

  }, 

  "scripts": {

    "contract:emit": "prisma contract emit",

    "migration:plan": "prisma migration plan", 

    "migration:status": "prisma migration status", 

    "db:migrate": "prisma db migrate --advance-ref db", 

    "db:verify": "prisma db verify", 

    "check-types": "tsc --noEmit"

  },

  "dependencies": {

    "@prisma/orm-postgres": "8.0.0",

    "dotenv": "^17.4.2"

  },

  "devDependencies": {

    "@prisma/cli-engine": "0.6.1",

    "@types/node": "26.4.1",

    "prisma": "8.0.0"

  }

}

Keep the versions orm init and the upgrade wrote for you; the ones shown are placeholders. The scripts replace the Prisma 7 trio of db:generate, db:migrate, and db:deploy:

  • contract:emit compiles contract.prisma into contract.json and contract.d.ts. It is offline and deterministic, which is what makes it cacheable in Turborepo.
  • migration:plan diffs the emitted contract against your migration history and writes a reviewable migration directory. Also offline.
  • db:migrate applies pending migrations to DATABASE_URL, in development and in CI alike. --advance-ref db records the applied state in migrations/app/refs/db.json, so the next migration:plan knows where to start from and plans only the delta.

Then create the package entrypoint. It re-exports the client and the contract type so apps import one module:

title="packages/database/src/index.ts"
export { db } from "./prisma/db";

export type { Contract } from "./prisma/contract.d";

This follows Turborepo's Just-in-Time packaging pattern: the package exports TypeScript source and the consuming app's bundler compiles it. Next.js handles this for workspace packages without extra configuration.

Go back to the project root and wire the database tasks into turbo.json:

cd ../..
title="turbo.json"
{

  "$schema": "https://turborepo.dev/schema.json",

  "ui": "tui",

  "globalEnv": ["DATABASE_URL"], 

  "tasks": {

    "build": {

      "dependsOn": ["^build", "^contract:emit"], 

      "inputs": ["$TURBO_DEFAULT$", ".env*"],

      "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**"]

    },

    "lint": {

      "dependsOn": ["^lint"]

    },

    "check-types": {

      "dependsOn": ["^check-types", "^contract:emit"] 

    },

    "dev": {

      "dependsOn": ["^contract:emit"], 

      "cache": false,

      "persistent": true

    },

    "contract:emit": { 

      "inputs": ["src/prisma/contract.prisma", "prisma.config.ts"], 

      "outputs": ["src/prisma/contract.json", "src/prisma/contract.d.ts"] 

    }, 

    "migration:plan": { 

      "dependsOn": ["contract:emit"], 

      "cache": false

    }, 

    "db:migrate": { 

      "cache": false

    } 

  }

}

What each entry does:

  • contract:emit is a normal cached task. Its inputs are the contract source and the Prisma config, and its outputs are the two emitted files. When nothing changed, Turborepo replays the cache hit; when the contract changed, it re-emits before anything depends on it.
  • build, dev, and check-types depend on ^contract:emit, so any app that depends on @repo/db gets a fresh contract before it compiles. In Prisma 7 this slot held db:generate; here the artifacts are committed, and the dependency keeps them from going stale when someone edits the contract and forgets to emit.
  • migration:plan depends on the package's own contract:emit, so a plan always diffs the current contract. Planning and migrating are never cached because they change files on disk and the database.
  • globalEnv lets DATABASE_URL from your shell reach every task and makes it part of the task hash. The .env* input on build does the same for the per-app .env files.

Plan the first migration from the project root. Turborepo runs contract:emit first, then passes --name init through to migration plan:

pnpm turbo run migration:plan --filter=@repo/db -- --name init
┌─ @repo/db#contract:emit > cache miss, executing b7a2c51b5beafae3

$ prisma contract emit

✔ Emitted contract.json and contract.d.ts

└─ @repo/db#contract:emit ──

┌─ @repo/db#migration:plan > cache bypass, force executing 6cfc90ea54b2156b

$ prisma migration plan --name init

✔ Planned 6 operation(s)

migrations/app/20260910T1601_init

├─ Create schema "public"

├─ 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"

from:       (baseline)

to:         91e7f9f035806fa2789a4d726ef7724cad434fd6b00014d47ebf12d6e6bb784e

ℹ DDL preview

CREATE SCHEMA IF NOT EXISTS "public";

CREATE TABLE "public"."Post" (

...

Turborepo shows the tasks in a terminal UI while they run and leaves each task's log in your terminal when it exits. Without a terminal, for example in CI, the Prisma CLI prints JSON instead; add --format human to the script to keep the human-readable output. See Global flags.

The command is offline: it writes packages/database/migrations/app/<timestamp>_init/ with the migration as TypeScript (migration.ts), the compiled operations (ops.json), and the history marker (migration.json), and prints the exact DDL it will run. Review it, then apply it:

pnpm turbo run db:migrate --filter=@repo/db
┌─ @repo/db#db:migrate > cache bypass, force executing d71a24f43fe14f8b

$ prisma db migrate --advance-ref db

✔ Applied 1 migration(s) (6 operation(s)) across 1 contract space(s)

App space

├─ Create schema "public"

├─ 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 91e7f9f035806fa2789a4d726ef7724cad434fd6b00014d47ebf12d6e6bb784e

✔ Advanced ref "db" → 91e7f9f035806fa2789a4d726ef7724cad434fd6b00014d47ebf12d6e6bb784e

└─ @repo/db#db:migrate ──

Commit the migrations/ directory with the rest of the package. The same db:migrate task is what CI or a deploy hook runs against staging and production; there is no separate migrate deploy command in Prisma 8.

Add @repo/db to apps/web/package.json:

title="apps/web/package.json"
{

  // ...

  "dependencies": {

    "@repo/db": "workspace:*", 

    "@repo/ui": "workspace:*",

    // ...

  }

  // ...

}

Link it from the project root:

pnpm install

Replace apps/web/app/page.tsx with a server component that queries the database through the shared client:

title="apps/web/app/page.tsx"
import { db } from "@repo/db";

import styles from "./page.module.css";

export const dynamic = "force-dynamic";

export default async function Home() {

  const users = await db.orm.public.User.select("id", "email", "name").all();

  return (

    <div className={styles.page}>

      <main className={styles.main}>

        <h1>Users</h1>

        {users.length === 0 ? (

          <p>No users added yet</p>

        ) : (

          <ul>

            {users.map((user) => (

              <li key={user.id}>

                {user.name ?? "Anonymous"} ({user.email})

              </li>

            ))}

          </ul>

        )}

      </main>

    </div>

  );

}

Model access is namespace-qualified on PostgreSQL, so the User model lives at db.orm.public.User, and select(...) narrows both the query and the row type. dynamic = "force-dynamic" tells Next.js to run the query per request; without it, next build prerenders the page once and freezes the user list at build time.

Each app reads its own .env, so copy the one from the database package:

cp packages/database/.env apps/web/.env

If you would rather keep a single value, export DATABASE_URL in your shell instead; the globalEnv entry from step 3 passes it into every task. Turborepo recommends per-package .env files; for one shared file across the monorepo, see the dotenvx guide for Turborepo.

The page will render "No users added yet" against an empty database. Add a small seed script to the database package so there is something to see. It runs with tsx, because Node.js needs file extensions on imports and the package uses extensionless, bundler-style imports:

pnpm add -D tsx --filter @repo/db
title="packages/database/src/seed.ts"
import { db } from "./index";

const existing = await db.orm.public.User.select("id").all();

if (existing.length === 0) {

  await db.orm.public.User.create({ email: "alice@prisma.io", name: "Alice" });

  await db.orm.public.User.create({ email: "bob@prisma.io", name: "Bob" });

}

console.log(await db.orm.public.User.select("id", "email", "name").all());

await db.close();

Add it as a script and run it:

title="packages/database/package.json"
{

  "scripts": {

    // ...

    "seed": "tsx src/seed.ts"

  }

}
pnpm --filter @repo/db seed
[

  { id: 1, email: 'alice@prisma.io', name: 'Alice' },

  { id: 2, email: 'bob@prisma.io', name: 'Bob' }

]

The script closes the client at the end because it is a one-off process; the web app never does, since its connection pool lives for the life of the server.

Start the web app from the project root:

pnpm turbo run dev --filter=web
▲ Next.js 16.3.4 (Turbopack)

- Local:         http://localhost:3000

- Environments: .env

✓ Ready in 1005ms

Turborepo runs contract:emit for @repo/db, then starts Next.js. Its terminal UI lists both tasks; select web#dev to see the Next.js log. Open http://localhost: the page lists the seeded users.

Users

Alice (alice@prisma.io)

Bob (bob@prisma.io)

pnpm turbo run build --filter=web and pnpm turbo run check-types go through the same ^contract:emit dependency, so a production build or a type check never sees a stale contract.

The loop for every later change is: edit the contract, plan, review, apply. Add a bio field to User:

title="packages/database/src/prisma/contract.prisma"
model User {

  id        Int      @id @default(autoincrement())

  email     String   @unique

  username  String?

  name      String?

  bio       String?

  posts     Post[]

  createdAt TimestamptzString @default(now())

  updatedAt temporal.updatedAtString()

}

Plan it. contract:emit sees the changed input and re-emits, and because db:migrate advanced the db ref, the planner starts from the applied state and plans only the delta:

pnpm turbo run migration:plan --filter=@repo/db -- --name add-user-bio
┌─ @repo/db#contract:emit > cache miss, executing 654198244cf388ef

$ prisma contract emit

✔ Emitted contract.json and contract.d.ts

└─ @repo/db#contract:emit ──

┌─ @repo/db#migration:plan > cache bypass, force executing 83824ab92bdf22eb

$ prisma migration plan --name add-user-bio

✔ Planned 1 operation(s)

migrations/app/20260910T1601_add_user_bio

└─ Add column "bio" to "User"

from:       91e7f9f035806fa2789a4d726ef7724cad434fd6b00014d47ebf12d6e6bb784e

to:         1cb334c8d48b39d9b29d14f8f8ad799c8d566dc9af2ba6c46a6d02dbb232d425

ℹ DDL preview

ALTER TABLE "public"."User" ADD COLUMN "bio" text;

└─ @repo/db#migration:plan ──

Apply it, then check where the database stands:

pnpm turbo run db:migrate --filter=@repo/db

pnpm --filter @repo/db migration:status
○   1cb334c  @contract @db (db)

│↑  20260910T1601_add_user_bio  91e7f9f → 1cb334c  1 ops  ✓ applied

○   91e7f9f

│↑  20260910T1601_init                ∅ → 91e7f9f  6 ops  ✓ applied

○   ∅

✔ Up to date

The next pnpm turbo run dev --filter=web re-emits the contract before Next.js starts, and user.bio is available in the page's types. For a local change you do not want to keep as a migration, npx prisma db update reconciles the database with the contract directly; see db update.

Run npx prisma@latest init once at the monorepo root to install the Prisma 8 skills for your coding agent and keep them matching your installed packages. It adds a root prisma.config.ts that only configures the skills, a prisma dev dependency, and a postinstall hook that re-syncs them. Prompts that map to this guide:

  • "Using the prisma-8 skill, add a apps/docs page that lists posts with their authors through @repo/db."
  • "Add a published Boolean @default(false) field to Post, plan the migration, and show me the DDL before applying it."
  • "Add a db:verify task to turbo.json and run it in CI after db:migrate."
Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu