Multiple databases
This guide shows you how to use two databases from one Next.js app with Prisma ORM. You give each database its own data contract, its own prisma.config.ts, and its own Prisma ORM client, then render data from both on one page. The same layout works for any number of databases and is a good fit for multi-tenant apps or for keeping unrelated data in separate databases.
Every command and code block below was run against two local PostgreSQL databases, users and posts.
- Node.js 24 or later
- Two PostgreSQL connection strings, or nothing at all:
npx create-db@latestcan create a Prisma Postgres database for you, run it twice for two - A Vercel account if you want to deploy at the end
To delegate this guide to your coding agent, copy the prompt below and hand it over:
Create a Next.js app that reads from two PostgreSQL databases with Prisma ORM, following https://www.prisma.io/docs/guides/database/multiple-databases.md.
1. Scaffold with `npx create-next-app@latest my-multi-db-app --yes`, then in the project run `npx prisma@latest orm init --yes --target postgres --authoring psl --schema-path ./prisma/users/contract.prisma`. Run `npx prisma@latest init` so the Prisma agent skills are installed, and use them.
2. Rename `prisma.config.ts` to `prisma.users.config.ts`, point it at `USERS_DATABASE_URL` and `./prisma/users/migrations`, and reduce `prisma/users/contract.prisma` to a `User` model. Create the same three files for the posts database: `prisma.posts.config.ts` reading `POSTS_DATABASE_URL`, `prisma/posts/contract.prisma` with a `Post` model that stores `authorId Int` (no relation across databases), and `prisma/posts/db.ts`. Export `usersDb` and `postsDb` from the two `db.ts` files.
3. Put both connection strings in `.env` (use the ones I give you, or create two Prisma Postgres databases with `npx create-db@latest` and show me the claim URLs). Add package scripts that run `prisma contract emit`, `prisma db init`, and `prisma db verify` once per config with `--config`, then run them.
4. Write a seed script that inserts two users and two posts, run it with `node prisma/seed.ts`, and rewrite `app/page.tsx` as a server component that lists users from `usersDb` and posts from `postsDb`, joining authors in application code.
5. Start `npm run dev` in the background, verify http://localhost:3000 renders both lists, stop the server, and confirm `npm run build` passes.Create a new Next.js app and accept the defaults (TypeScript, Tailwind CSS, App Router, no src directory):
bunx create-next-app@latest my-multi-db-app
cd my-multi-db-apppnpm dlx create-next-app@latest my-multi-db-app
cd my-multi-db-appyarn dlx create-next-app@latest my-multi-db-app
cd my-multi-db-appnpx create-next-app@latest my-multi-db-app
cd my-multi-db-appThis guide starts from create-next-app and adds Prisma ORM with orm init instead of using the next template of create-prisma. That template wires a single database and declares it for Prisma Compute; here you want exactly the Prisma files, once per database, and nothing else.
This guide starts from create-next-app and adds Prisma ORM with orm init instead of using the next template of create-prisma. That template wires a single database and declares it for Prisma Compute; here you want exactly the Prisma files, once per database, and nothing else.
Run orm init from the project root. The --schema-path flag puts the contract in a folder named after the database, and orm init places the client file next to it:
bunx prisma@latest orm init --yes --target postgres --authoring psl --schema-path ./prisma/users/contract.prismapnpm dlx prisma@latest orm init --yes --target postgres --authoring psl --schema-path ./prisma/users/contract.prismayarn dlx prisma@latest orm init --yes --target postgres --authoring psl --schema-path ./prisma/users/contract.prismanpx prisma@latest orm init --yes --target postgres --authoring psl --schema-path ./prisma/users/contract.prismaThe command installs @prisma/orm-postgres and dotenv, adds prisma as a dev dependency, sets "type": "module" in package.json, updates tsconfig.json, and writes these files:
prisma/users/contract.prisma
prisma/users/db.ts
prisma.config.ts
prisma-8.md
.env.exampleThere is no prisma generate and no generated client package. The emitted contract.json and contract.d.ts next to the contract are what the runtime and the type checker read.
The command installs @prisma/orm-postgres and dotenv, adds prisma as a dev dependency, sets "type": "module" in package.json, updates tsconfig.json, and writes these files:
prisma/users/contract.prisma
prisma/users/db.ts
prisma.config.ts
prisma-8.md
.env.exampleThere is no prisma generate and no generated client package. The emitted contract.json and contract.d.ts next to the contract are what the runtime and the type checker read.
2.1. Give the config a database-specific name
Section titled “2.1. Give the config a database-specific name”Prisma ORM commands read ./prisma.config.ts by default and take a different file with the global --config flag. Rename the generated config so each database has one:
mv prisma.config.ts prisma.users.config.tsPoint it at the users contract, a users-only migrations folder, and a USERS_DATABASE_URL variable:
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: "./prisma/users/contract.prisma",
migrations: {
dir: "./prisma/users/migrations",
},
db: {
connection: process.env.USERS_DATABASE_URL!,
},
}),
});migrations.dir matters once you use checked-in migrations: without it both configs would write to the same ./migrations folder.
Replace the starter contract with a single User model:
// use prisma-8
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
createdAt TimestamptzString @default(now())
}Change prisma/users/db.ts to read USERS_DATABASE_URL and export a name you can tell apart from the second client:
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 usersDb = postgres<Contract>({
contractJson,
url: process.env.USERS_DATABASE_URL!,
});contract.d.ts and contract.json do not exist yet; you emit them in step 4.
The second database needs the same three files. Create them by hand; running orm init again would replace the files from step 2.
mkdir -p prisma/postsimport 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: "./prisma/posts/contract.prisma",
migrations: {
dir: "./prisma/posts/migrations",
},
db: {
connection: process.env.POSTS_DATABASE_URL!,
},
}),
});// use prisma-8
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
createdAt TimestamptzString @default(now())
}authorId is a plain integer, not a relation. A contract describes one database, and PostgreSQL cannot enforce a foreign key into another database, so the link between a post and its author is resolved in application code in step 5.
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 postsDb = postgres<Contract>({
contractJson,
url: process.env.POSTS_DATABASE_URL!,
});Now set both connection strings. Both config files and both clients load .env through dotenv/config, and Next.js loads it as well:
USERS_DATABASE_URL="postgres://user:password@localhost:5432/users"
POSTS_DATABASE_URL="postgres://user:password@localhost:5432/posts"orm init already added .env to .gitignore. Update .env.example with the two variable names so collaborators know what to set.
4. Emit the contracts and initialize both databases
Section titled “4. Emit the contracts and initialize both databases”Each Prisma ORM command works on one config. Add scripts that run every command once per database so you do not have to type --config twice:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"contract:emit": "prisma contract emit --config ./prisma.users.config.ts && prisma contract emit --config ./prisma.posts.config.ts",
"db:init": "prisma db init --config ./prisma.users.config.ts && prisma db init --config ./prisma.posts.config.ts",
"db:verify": "prisma db verify --config ./prisma.users.config.ts && prisma db verify --config ./prisma.posts.config.ts",
"db:update": "prisma db update --config ./prisma.users.config.ts && prisma db update --config ./prisma.posts.config.ts",
"db:seed": "node prisma/seed.ts",
"prebuild": "npm run contract:emit"
}prebuild re-emits both contracts before every next build, locally and on Vercel, so the build never runs against stale artifacts.
Emit the contract artifacts for both databases. This step is offline:
bun run contract:emitpnpm run contract:emityarn contract:emitnpm run contract:emit✔ Emitted contract.json and contract.d.ts
│ contract: prisma/users/contract.json
│ types: prisma/users/contract.d.ts
✔ Emitted contract.json and contract.d.ts
│ contract: prisma/posts/contract.json
│ types: prisma/posts/contract.d.tsCreate the tables in both databases and sign each one with its contract:
✔ Emitted contract.json and contract.d.ts
│ contract: prisma/users/contract.json
│ types: prisma/users/contract.d.ts
✔ Emitted contract.json and contract.d.ts
│ contract: prisma/posts/contract.json
│ types: prisma/posts/contract.d.tsCreate the tables in both databases and sign each one with its contract:
bun run db:initpnpm run db:inityarn db:initnpm run db:init│ contract: prisma/users/contract.json
│ database: postgres://****@localhost:5432/users
✔ Applied 2 operation(s) across 1 contract space
App space
├─ Create table "User"
├─ Add unique constraint on "User" (email)
└─ marker f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7
│ contract: prisma/posts/contract.json
│ database: postgres://****@localhost:5432/posts
✔ Applied 1 operation(s) across 1 contract space
App space
├─ Create table "Post"
└─ marker a0c012793cc392569087d82d7ea41686c8e60586b63c7a131391b99717157804Each database now carries a marker with the hash of the contract it was initialized from. Confirm both match:
│ contract: prisma/users/contract.json
│ database: postgres://****@localhost:5432/users
✔ Applied 2 operation(s) across 1 contract space
App space
├─ Create table "User"
├─ Add unique constraint on "User" (email)
└─ marker f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7
│ contract: prisma/posts/contract.json
│ database: postgres://****@localhost:5432/posts
✔ Applied 1 operation(s) across 1 contract space
App space
├─ Create table "Post"
└─ marker a0c012793cc392569087d82d7ea41686c8e60586b63c7a131391b99717157804Each database now carries a marker with the hash of the contract it was initialized from. Confirm both match:
bun run db:verifypnpm run db:verifyyarn db:verifynpm run db:verify│ contract: prisma/users/contract.json
│ database: postgres://****@localhost:5432/users
✔ Database marker and schema match contract
│ contract: prisma/posts/contract.json
│ database: postgres://****@localhost:5432/posts
✔ Database marker and schema match contractThe marker is also what protects you from crossing the wires. Verifying the users contract against the posts database stops immediately:
│ contract: prisma/users/contract.json
│ database: postgres://****@localhost:5432/users
✔ Database marker and schema match contract
│ contract: prisma/posts/contract.json
│ database: postgres://****@localhost:5432/posts
✔ Database marker and schema match contractThe marker is also what protects you from crossing the wires. Verifying the users contract against the posts database stops immediately:
bunx prisma db verify --config ./prisma.users.config.ts --db "$POSTS_DATABASE_URL"pnpm prisma db verify --config ./prisma.users.config.ts --db "$POSTS_DATABASE_URL"yarn prisma db verify --config ./prisma.users.config.ts --db "$POSTS_DATABASE_URL"npx prisma db verify --config ./prisma.users.config.ts --db "$POSTS_DATABASE_URL"✘ [CONTRACT.MARKER_MISMATCH] Hash mismatch
why: Contract storageHash does not match database marker✘ [CONTRACT.MARKER_MISMATCH] Hash mismatch
why: Contract storageHash does not match database markerCreate a script that writes through both clients. Node.js 24 runs TypeScript directly, so no extra tooling is needed:
import { usersDb } from "./users/db.ts";
import { postsDb } from "./posts/db.ts";
async function main() {
const alice = await usersDb.orm.public.User.create({
email: "alice@prisma.io",
name: "Alice",
});
const bob = await usersDb.orm.public.User.create({
email: "bob@prisma.io",
name: "Bob",
});
await postsDb.orm.public.Post.create({
title: "Hello from the posts database",
content: "This row lives in the posts database.",
authorId: alice.id,
});
await postsDb.orm.public.Post.create({
title: "Two databases, one app",
content: null,
authorId: bob.id,
});
console.log("Seeded", alice.email, "and", bob.email, "with one post each.");
await usersDb.close();
await postsDb.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});The .ts extensions in the imports are what Node.js needs to resolve the files. Allow them in tsconfig.json, otherwise next build fails its type check with TS5097:
{
"compilerOptions": {
"allowImportingTsExtensions": true
}
}Run the seed:
bun run db:seedpnpm run db:seedyarn db:seednpm run db:seedSeeded alice@prisma.io and bob@prisma.io with one post each.Both clients open their connection pool on the first query. The script closes them at the end so the process can exit.
Seeded alice@prisma.io and bob@prisma.io with one post each.Both clients open their connection pool on the first query. The script closes them at the end so the process can exit.
Replace app/page.tsx with a server component that queries each client and joins posts to their authors in memory:
import { usersDb } from "@/prisma/users/db";
import { postsDb } from "@/prisma/posts/db";
export const dynamic = "force-dynamic";
export default async function Home() {
const users = await usersDb.orm.public.User.select("id", "email", "name").all();
const posts = await postsDb.orm.public.Post
.select("id", "title", "authorId")
.orderBy((p) => p.id.asc())
.all();
const authorById = new Map(users.map((u) => [u.id, u]));
return (
<main className="mx-auto max-w-2xl p-8 font-sans">
<h1 className="text-3xl font-bold">Multi-database showcase</h1>
<p className="mt-2 text-zinc-600">
Users come from one PostgreSQL database, posts from another.
</p>
<h2 className="mt-8 text-xl font-semibold">Users</h2>
<ul className="mt-2 list-disc pl-6">
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
<h2 className="mt-8 text-xl font-semibold">Posts</h2>
<ul className="mt-2 list-disc pl-6">
{posts.map((post) => (
<li key={post.id}>
{post.title}, by {authorById.get(post.authorId)?.name ?? "unknown"}
</li>
))}
</ul>
</main>
);
}Each client is typed by its own contract.d.ts: usersDb.orm.public only knows User, postsDb.orm.public only knows Post, and mixing them up is a type error. force-dynamic keeps Next.js from trying to render the page at build time, when no database is reachable.
bun run devpnpm run devyarn devnpm run dev▲ Next.js 16.3.4 (Turbopack)
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 894msOpen http://localhost:3000. The page lists Alice and Bob under Users and the two posts under Posts, each attributed to its author. One request, two databases, no adapter or extra client package in between.
Finally, confirm the production build passes. prebuild emits both contracts first:
▲ Next.js 16.3.4 (Turbopack)
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 894msOpen http://localhost. The page lists Alice and Bob under Users and the two posts under Posts, each attributed to its author. One request, two databases, no adapter or extra client package in between.
Finally, confirm the production build passes. prebuild emits both contracts first:
bun run buildpnpm run buildyarn buildnpm run build> prisma contract emit --config ./prisma.users.config.ts && prisma contract emit --config ./prisma.posts.config.ts
...
✓ Compiled successfully> prisma contract emit --config ./prisma.users.config.ts && prisma contract emit --config ./prisma.posts.config.ts
...
✓ Compiled successfullyThe app needs two environment variables in production and nothing else that is Prisma-specific: prebuild runs on Vercel, and there is no client to generate and no engine binary to ship.
-
Push the project to a GitHub repository. If you do not have one yet, create one on GitHub, then run:
git add . git commit -m "Next.js app with two Prisma 8 databases" git branch -M main git remote add origin https://github.com/<your-username>/<repository-name>.git git push -u origin main -
In the Vercel dashboard, follow Import an existing project and stop at the step where you configure the project, before clicking Deploy.
-
Expand Environment variables and add both connection strings:
- Key:
USERS_DATABASE_URL, Value: the users database connection string from your.env - Key:
POSTS_DATABASE_URL, Value: the posts database connection string from your.env
- Key:
-
Click Deploy. Vercel runs
npm run build, which emits both contracts and builds the app.
Open the live URL. The page renders the same two lists from the same two databases. The Vercel deploy was not run while validating this guide; the npm run build it executes is the one from step 5.
- No relations across databases. A contract covers one database. Keep a plain id column such as
authorIdon the side that references the other database, and join in application code. npx prisma@latest initwrites a third config. It creates aprisma.config.tsthat holds only theskillssection for agent skills. That is expected; the database commands keep using--configwith the two database configs.- Do not close a client per request. In
page.tsxand route handlers, never callusersDb.close(). The pools are shared across requests and close when the process exits. Only scripts such asprisma/seed.tsclose them. orm initpicks the package manager from your lockfile. Run it aftercreate-next-apphas writtenpackage-lock.json,pnpm-lock.yaml, orbun.lock, or it may install with a different package manager than the one you use.
Run npx prisma@latest init once to install the Prisma ORM skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, add a
Commentmodel toprisma/posts/contract.prisma, emit with--config ./prisma.posts.config.ts, and update the posts database." - "Add a third database for billing following the same layout:
prisma.billing.config.ts,prisma/billing/contract.prisma,prisma/billing/db.ts, and extend the package scripts." - "Write a route handler that creates a post for the signed-in user, validating that the
authorIdexists in the users database first."
- Change a contract, then run
npm run contract:emitandnpm run db:updateto apply the change to both databases in development. For checked-in migrations, runmigration plananddb migratewith the--configof the database you changed; each writes to its ownmigrations.dir. - Learn the fundamentals: filtering, sorting, pagination, and writes.
- CLI configuration covers everything
prisma.config.tscan hold. - Splitting the app itself into packages? See pnpm workspaces and Turborepo.