What is a shared database library in an Nx Prisma monorepo, and why does one database need one migration history?

In an Nx Prisma monorepo, a shared database library is a single Nx library that owns everything describing one database. It holds the schema.prisma file, the migrations directory that Prisma generates from it, and a configured PrismaClient export. Every app that talks to that database, such as an API, a worker, or an admin backend, imports the client from this library. No app defines its own schema.

The reason for this is how Prisma tracks state. Prisma migrations are recorded in a _prisma_migrations table inside the database. Each row names a migration folder and stores a checksum of its SQL. Prisma compares that table against the migrations directory it is pointed at.

Problems start when two apps each keep their own schema and migrations folder against the same database:

  • Unknown migrations. App A's migrations show up in the table, and App B's tooling sees them as applied migrations that are missing locally.
  • Drift and resets. App B's schema doesn't describe tables it never created, so prisma migrate dev detects drift and may propose resetting the database.
  • Conflicting DDL. Two teams can generate competing changes for the same table, and neither migration history describes the real database.

The rule is simple: one schema file, one migrations directory, one owning project.

This pattern fits teams running several services or apps against one shared database, where the schema changes together and a single deploy step can apply migrations. If each service owns its own database, you don't need it.

+-------------+    +-------------+    +---------------+
|  apps/api   |    | apps/worker |    |  apps/admin   |
+------+------+    +------+------+    +-------+-------+
       |                  |                   |
       +------------------+-------------------+
                          |
                          |  import { prisma } from libs/db
                          v
        +-----------------------------------+
        |  libs/db  (single owning project) |
        |                                   |
        |  prisma/schema.prisma   (1 file)  |
        |  prisma/migrations/     (1 dir)   |
        |  src/index.ts -> PrismaClient     |
        +-----------------+-----------------+
                          |
                          |  prisma migrate deploy
                          |  (one deploy step)
                          v
        +-----------------------------------+
        |        Shared database            |
        |                                   |
        |  app tables                       |
        |  _prisma_migrations               |
        |  (one history: name + checksum)   |
        +-----------------------------------+

Several Nx apps import one configured PrismaClient from a single database library, which owns the only schema.prisma and migrations directory, so the shared database records one consistent _prisma_migrations history.

How do you structure the shared database library?

Put everything that touches the database into one Nx library, for example libs/db. Inside it, prisma/schema.prisma defines the models and generator, and prisma/migrations/ holds the SQL migration history that Prisma creates and applies. Next to these, a small source module (say src/client.ts) creates and exports one configured PrismaClient instance. It can also set logging options or a connection override. The library's index.ts re-exports that client and any generated types the apps need.

In tsconfig.base.json, map an alias such as @acme/db to the library's entry point under compilerOptions.paths. Apps then import the client from @acme/db. Apps must never import @prisma/client directly. That import goes around the library, and Nx sees only a dependency on an npm package, not on libs/db. As a result, the project graph misses the real coupling, and nx affected cannot tell which apps a schema change touches.

Next, choose where the generated client goes. If you set the generator's output to a folder inside the library, such as libs/db/src/generated, the generated code stays with the project that owns it. Nx can then declare it as a target output and cache it. Add that folder to .gitignore and to your ESLint ignore patterns, because generated code produces noisy diffs and lint errors.

If you leave the client in node_modules, it sits outside every project. Nx cannot track it as an output, and restoring it from cache becomes unreliable.

Some bundlers also leave out the engine files Prisma needs at runtime unless you configure them to include those files. The exact fix depends on your bundler and Prisma setup, so test a production build early.

// libs/db/prisma/schema.prisma (generator block, shown here for context):
//   generator client {
//     provider = "prisma-client-js"
//     output   = "../src/generated/client"  // inside the lib; gitignored and lint-ignored
//   }

// libs/db/src/client.ts: the one place that constructs a PrismaClient
import { PrismaClient } from './generated/client';

export const prisma = new PrismaClient({
  // Verbose logging locally, quieter in production
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'warn', 'error']
    : ['warn', 'error'],
});

// libs/db/src/index.ts: the library's public surface
export { prisma } from './client';
// Re-export only the generated types apps actually need
export type { Prisma, User } from './generated/client';

// tsconfig.base.json (excerpt):
//   "compilerOptions": {
//     "paths": { "@acme/db": ["libs/db/src/index.ts"] }
//   }

// apps/api/src/users.ts: apps go through the alias
import { prisma, type User } from '@acme/db';
// Never: import { PrismaClient } from '@prisma/client';
// That bypasses libs/db, so Nx can't link this app to schema changes.

export async function findUserByEmail(email: string): Promise<User | null> {
  return prisma.user.findUnique({ where: { email } });
}

A shared libs/db library generates the Prisma client into its own source tree, exports one configured PrismaClient through an index.ts mapped to @acme/db in tsconfig.base.json, and an app imports from that alias instead of @prisma/client.

How do Nx targets run Prisma migrations from one place?

Put all Prisma commands in the library's project.json so that no app, script or CI job calls the Prisma CLI directly. Each target uses the nx:run-commands executor with cwd set to the workspace root and an explicit --schema path, such as libs/db/prisma/schema.prisma. The paths are fixed, so the targets behave the same whether you run them from the repo root, from an app folder or from a CI step. Prisma never has to search for the schema on its own.

Target Command Cached Runs where
generate prisma generate --schema=… Yes Local and CI
migrate-dev prisma migrate dev --schema=… No Local only
migrate-deploy prisma migrate deploy --schema=… No CI/CD against real environments
migrate-status prisma migrate status --schema=… No Local and CI (optional check)

Set generate as a dependency of every consuming app's build, serve and test targets through dependsOn. The client is then always rebuilt from the current schema before the app runs. Without this dependency, a fresh checkout or a schema change can leave an app compiling against stale types.

generate is deterministic, which makes it safe to cache. Declare schema.prisma as its input and the generated client directory as its output. A custom client output path inside the library makes this output easy for Nx to track and restore. On a cache hit, Nx restores the client without running Prisma.

The migrate-* targets read from or change a live database, so set cache: false on each of them. The database's state is not one of Nx's inputs. If migrate-deploy were cached, an unchanged schema could produce a cache hit. Nx would then report success even though the migrations were never applied to the target database.

{
  "name": "db",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "libs/db/src",
  "projectType": "library",
  "targets": {
    "generate": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["{projectRoot}/prisma/schema.prisma"],
      "outputs": ["{projectRoot}/src/generated/client"],
      "options": {
        "cwd": ".",
        "command": "npx prisma generate --schema=libs/db/prisma/schema.prisma"
      }
    },
    "migrate-dev": {
      "executor": "nx:run-commands",
      "cache": false,
      "options": {
        "cwd": ".",
        "command": "npx prisma migrate dev --schema=libs/db/prisma/schema.prisma"
      }
    },
    "migrate-deploy": {
      "executor": "nx:run-commands",
      "cache": false,
      "options": {
        "cwd": ".",
        "command": "npx prisma migrate deploy --schema=libs/db/prisma/schema.prisma"
      }
    },
    "migrate-status": {
      "executor": "nx:run-commands",
      "cache": false,
      "options": {
        "cwd": ".",
        "command": "npx prisma migrate status --schema=libs/db/prisma/schema.prisma"
      }
    }
  }
}

The libs/db project.json: generate is cached with schema.prisma as input and the client folder as output, while each migrate- target sets cache to false; apps then add {"projects": ["db"], "target": "generate"} to dependsOn.*

How does nx affected respond to schema changes, and how does this save engineering time?

Generation is the opposite case. Its output depends only on schema.prisma, so caching it is safe. That difference is also why the dependency graph matters in this section. Every app imports the generated client from the database library, so Nx records an edge from each app to that library. When you edit schema.prisma, Nx marks the library as affected, and every app that consumes it becomes affected too. This is the signal you want, because a schema change can break any query in any of those apps.

You can check this yourself. Run nx graph and confirm that each app has an edge to the database library. Then edit the schema and run nx show projects --affected --base=main. Every consuming app should appear in the list. Running nx affected -t test will then test exactly that set.

This setup saves time in several ways:

  • You maintain one set of migration targets instead of a copied script in each app.
  • Client generation runs as a dependency of the tasks that need it, so nobody has to remember to run it.
  • Generation is cached, so it costs almost nothing when the schema has not changed.
  • Drift incidents become less common, because no app can ship against a stale client.

Illustrative estimate (assumed numbers, not a benchmark):

  • Formula: minutes saved per month = apps × schema changes per month × minutes of manual work per app.
  • Assumed inputs: 5 apps, 8 schema changes per month, and 6 minutes of manual work per app.
  • Result: 5 × 8 × 6 = 240 minutes saved per month.

There is also a cost. Every schema change makes every consuming app affected, so in a large workspace these changes can noticeably lengthen CI runs.

# Assumes the shared library is the Nx project "database" and its schema
# lives at libs/database/prisma/schema.prisma. Adjust names to your workspace.

# 1. Inspect the dependency graph around the database library.
#    Every app that imports the generated client should show an edge to it.
npx nx graph --focus=database

# 2. Simulate a schema change on a feature branch.
git checkout -b schema-change-demo
echo '// demo edit' >> libs/database/prisma/schema.prisma

# 3. List projects affected relative to main.
#    With only --base, Nx also includes uncommitted changes.
#    Expected: "database" plus every app that consumes it.
npx nx show projects --affected --base=main

# 4. Run tests for exactly that affected set.
npx nx affected -t test --base=main

# 5. Clean up the demo edit.
git checkout -- libs/database/prisma/schema.prisma
git checkout main
git branch -D schema-change-demo

Shell steps that confirm, through the project graph and nx affected, that editing the shared schema.prisma marks the database library and every consuming app as affected, then run tests for only that set.

How should you deploy and write migrations when several apps share the schema?

Run prisma migrate deploy exactly once per environment, as its own pipeline step or a single job, before rolling out any app that depends on the new schema. Do not put it in app startup commands. With several apps and several replicas each, every container would try to apply the same migrations at boot. You would be relying on locking behavior to sort that out, and a failed migration would surface as a crash loop instead of a clear pipeline failure.

A dedicated step costs some orchestration work, and you have to handle three things:

  • Ordering: app deploys must wait for the migration step to succeed. Make that dependency explicit in the pipeline.
  • Failure handling: if the migration fails, stop the pipeline. Then fix the migration, or mark its state before retrying.
  • Partial success: the migration can succeed while an app deploy fails. Applied migrations are not rolled back automatically, so the database stays ahead of the running code. This is only safe if old code tolerates the new schema.

That is why migrations should follow expand-and-contract:

  1. Expand. Add nullable columns or new tables first.
  2. Deploy consumers. Roll out every app that uses the new structures.
  3. Contract. Drop the old structures in a later release.

Treat a rename as four steps: add the new column, backfill it, switch readers and writers, then drop the old column. Prisma may generate a rename as a drop plus an add, which loses data. Always review the generated SQL before committing.

If you skip this and ship a destructive change in one step, apps still running the previous Prisma client will query columns that no longer exist and fail at runtime.

What does the local developer workflow look like?

Each developer works against a local or disposable database, usually a container started per checkout. They run migrate dev through the library's Nx target, never by calling the Prisma CLI from an app directory. The target pins the schema path, so every migration lands in the library's single migrations folder. Commit the generated migration folder and the schema.prisma change together in one commit. A schema edit without its migration, or a migration without its schema edit, leaves the next person's migrate dev reporting drift.

When two branches both add migrations, do not hand-merge the folders. Prisma applies migrations in timestamp order, and two folders written against different baselines can conflict silently. After rebasing onto main, delete your branch's migration folder, reset your local database so it replays main's history, and run migrate dev again. Prisma then generates one migration against the real current state.

Environment configuration needs one rule: a single DATABASE_URL variable, read the same way by the library target and by every app. Nx loads .env files from the workspace root and from each project's root when it runs a task. A stray .env in one app can therefore point that app at a different database than the one the migration target just changed. The Prisma CLI can also load its own .env, which makes the mismatch easier to miss. Keep the value in one root-level file and keep it out of project-level files.

migrate dev also needs a shadow database. It creates and drops this database to detect drift, so the database user needs permission to create databases. Managed dev databases often deny that permission or require you to configure a separate shadow URL. This is a common reason teams move to disposable local databases, where the developer has full control.

When should you avoid a shared Prisma library, and what are the alternatives?

A shared schema library makes every app a dependent of every table. In the Nx project graph, a column added for the billing service marks the notifications app as affected too, so CI rebuilds and retests code that cannot observe the change. The library also becomes a single ownership point. Either one team reviews every migration and turns into a bottleneck, or nobody does and the schema becomes a dumping ground for half-owned tables. Expand-and-contract adds its own cost: a column renamed for one app survives as dead weight, with compatibility code paths, until the slowest consuming app has deployed and the contract migration finally runs.

Avoid the shared library when apps model the same nouns differently, for example "order" in fulfilment versus "order" in analytics, or when teams need to ship schema changes without coordinating releases. In those cases the shared history adds coordination cost without making the data more consistent.

Approach Coupling CI cost Ownership burden Migration coordination
Single shared library, one database Every app to every table Any schema change affects all dependents Concentrated in one library One history; contract steps wait on all apps
Schema library per bounded context, separate databases Within a context only Changes affect only that context's apps Owned by the context's team Per context; cross-context data moves through APIs or events
Separate database per app None at the schema level Changes affect one app Owned by each app team Independent; shared data needs duplication or integration

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx prisma monorepo, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.