Connecting a database
Connect a Next.js app to Postgres: choose Drizzle or Prisma, see the SQL your queries send, pool connections, keep one client in development, and seed data.
- 01Choose a database and an ORMCompare Postgres, SQLite and MySQL, a plain driver and an ORM, and Drizzle and Prisma, with the versions current today.
- 02Set up the client wellOpen one client for each server process, size its connection pool, and keep it on globalThis while next dev reloads your files.
- 03Read and seed the dataWrite Drizzle queries, read the SQL they send, and seed the first rows with a script that's safe to run again.
The tea shop's teas move into Postgres
In Fetching data in Server Components, the tea shop's database was one file, and the page wrote its SQL by hand. A real app needs a little more around its database: a description of its tables, one client that every request shares, and a way to put the first rows in. Many apps also use an ORM (object-relational mapper): a library that lets you describe tables and write queries in your own language, and writes the SQL for you. Here the shop keeps its teas in Postgres and reads them with Drizzle ORM.
This example is a real Next.js 16.4.3 project, built with next build and run with next start. Its database is PGlite again: Postgres compiled to WebAssembly, running inside the app, in memory, so the recording needs no database server. The rest is what a project with a Postgres server has: a schema, a client, a seed, and a data module. Click through the files; the schema opens first.
db/schema.tsimport { integer, pgTable, text } from "drizzle-orm/pg-core"; export const teas = pgTable("teas", { id: text().primaryKey(), name: text().notNull(), price: integer().notNull(), stock: integer().notNull().default(0),});- Green tea: $4
- Lemon tea: $3
- Mint tea: $3
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
db/schema.ts describes the teas table in TypeScript. Line 3 names it with pgTable, and lines 4 to 7 are its columns: an id that's the primary key (no two teas can share one), a name and a price that can't be empty, and a stock that starts at 0.
In db/index.ts, line 6 opens the database, and line 7 wraps it with Drizzle: db is what every query uses, and logger: true prints each query it sends, with its values. The recordings use it to show you the SQL; keep it to development, because the values can hold private data, like a customer's email address. Lines 11 to 13 make the table, and line 14 calls seed, which puts in the first three teas. In lib/teas.ts, the data module from the last lesson, line 9 is the query: three columns of every tea, sorted by name. The page calls getTeas() inside Suspense, as before.
Open the Terminal tab and scroll to the end, where next start's output follows the build's. The server printed Opening the database once, then the seed's insert, then for each of the two visits the SQL that Drizzle wrote for line 9: select "id", "name", "price" from "teas" order by "teas"."name" asc. The build printed Opening the database too, twice in this recording, while it collected the pages: next build loads your server code to look at each page. How many times is a detail of this build, not something to count on.
getTeas(). Drizzle turns the TypeScript query into SQL and sends it to Postgres, then turns the rows that come back into objects. The recordings' Terminal shows the SQL for each visit.The shop keeps its tea in a warehouse. Assistants don't walk over for every order: they fill in an order form the warehouse understands, and phone it through on one of a few lines the shop pays for. Opening a new line takes a while, so the assistants share the ones they have. On opening day, the warehouse got its first boxes, once.
- In real life: The warehouse, where every box of tea is kept
- In Next.js: Postgres, where the rows are kept
- In real life: The order form, which turns "all teas, by name" into the warehouse's own words
- In Next.js: Drizzle, which turns your query into SQL
- In real life: A few phone lines to the warehouse, shared by every assistant
- In Next.js: A connection pool, shared by every request
- In real life: The first boxes delivered on opening day
- In Next.js: The seed: the first rows
Connect once, query often. Each server process opens one client, with a small pool of connections that every request shares. Read and write through an ORM that writes the SQL and passes your values as parameters. Make the tables with the ORM's migration tool, and put the first rows in with a seed that's safe to run twice.
Choosing a database and an ORM
Two choices come first: which database holds the data, and how your code talks to it.
Which database
Each row is one database: what it is, and when it fits.
| The database | What it is | When it fits |
|---|---|---|
| Postgres | A database server, open source, with tables, SQL, and many extensions. | Most new full-stack apps. Many hosts run it for you. |
| SQLite | A whole database in one file, read by a library inside your app. | Small apps on one server, prototypes and tests. Hosted versions, like Turso, add a server. |
| MySQL | A database server, open source, as widely hosted as Postgres. | Teams and hosts that already run it. |
| PGlite | Postgres compiled to WebAssembly, running inside your app. | Tests, demos and this course's recordings. It isn't a server that many app instances share. |
Postgres is a common choice for a new Next.js app, and many hosts run it for you: you get a connection string, an address with the user and password in it, like postgres://user:password@host:5432/teashop, and keep it in a server environment variable, DATABASE_URL. Next.js doesn't mind which you pick: a Server Component calls whatever client you install.
How your code talks to it
Each row is one way to send a query: what you write, and how it looks.
| The tool | What you write | A query for one tea |
|---|---|---|
A driver, like pg | SQL text, with parameters. | pool.query("SELECT name FROM teas WHERE id = $1", [id]) |
| An ORM that reads like SQL, like Drizzle | TypeScript that builds the SQL. | db.select().from(teas).where(eq(teas.id, id)) |
| An ORM built on models, like Prisma | Calls on a model, with options. | prisma.tea.findMany({ where: { id } }) |
A driver sends the SQL you write; the last lesson's db.query worked that way. An ORM lets you describe the tables once and write queries as code. It writes the SQL, passes values as parameters, and, with TypeScript, knows each row's fields, so a misspelled column is an error in your editor instead of a failed query.
Drizzle and Prisma
Drizzle and Prisma are two popular ORMs for TypeScript, and both document how to use them with Next.js. This is where both stand on October 30, 2026, from each project's docs and from npm.
Drizzle ORM
Drizzle describes tables in TypeScript and writes queries that read like SQL. The recordings use drizzle-orm 0.45.4, the latest on npm. Its command-line tool, drizzle-kit (0.31.11), makes and changes the tables from the schema. Drizzle's own docs already install a 1.0 release candidate, with npm i drizzle-orm@rc; check its upgrade notes before you move to it. Each button below is a version of line 9 of lib/teas.ts. Before you click, guess the SQL each one sends.
lib/teas.tsNext.jslib/teas.tsimport "server-only";import { connection } from "next/server";import { asc } from "drizzle-orm";import { db } from "@/db";import { teas } from "@/db/schema"; export async function getTeas() { await connection(); return db.select({ id: teas.id, name: teas.name, price: teas.price }).from(teas).orderBy(asc(teas.name));}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Opening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]Opening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ◐ /└ ○ /_not-found ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyOpening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]Query: select "id", "name", "price" from "teas" order by "teas"."name" ascQuery: select "id", "name", "price" from "teas" order by "teas"."name" ascLine 9 asks for three columns of every tea, sorted by name. Drizzle sends select "id", "name", "price" from "teas" order by "teas"."name" asc, with no parameters.
Drizzle builds one SQL statement from each chain: select picks the columns, from the table, where adds a condition, and orderBy and limit come last. Values like 0 and 2 never go into the SQL text. They travel on their own, as parameters ($1). That prevents SQL injection: a value from outside, like a visitor's search, changing the SQL command itself, because the database never reads a parameter as part of the command. Each version waits for connection() on line 8 for the reason Fetching data in Server Components showed: PGlite reads the clock while it runs, which Next.js stops during a prerender. A query to a database server suspends on its own and doesn't need the line.
Prisma ORM
Prisma describes tables in its own language, in a schema.prisma file, and generates a typed client from it. This is Prisma ORM 7, the current stable release (@prisma/client 7.10.1), written the way Prisma's Next.js guide sets it up. It isn't part of the recordings, so it doesn't run here:
generator client {
provider = "prisma-client"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Tea {
id String @id
name String
price Int
stock Int @default(0)
@@map("teas")
}Lines 1 to 4 tell prisma generate to write the client into app/generated/prisma. Lines 6 to 8 say the database is Postgres; its address comes from prisma.config.ts. Lines 10 to 17 describe the same teas table as a model, Tea. Line 16, @@map("teas"), names the table: without it, Prisma would look for a table named Tea, not the teas table the Drizzle schema makes. A query reads like this:
const teas = await prisma.tea.findMany({
select: { id: true, name: true, price: true },
orderBy: { name: "asc" },
});findMany on line 1 reads many rows. Line 2 picks the fields, and line 3 sorts by name: the same rows as Drizzle's line 9, written as options instead of SQL-like steps.
On October 30, 2026, Prisma ORM 8 is a release candidate with a new way of working: a contract file instead of schema.prisma, a library named @prisma/orm-postgres instead of @prisma/client, and queries like db.orm.public.User. npm's latest prisma command-line tool is already 8.0.0-rc.28, so npm install prisma installs it. Prisma's docs say to install prisma@7 and @prisma/client@7 if you follow the Prisma 7 setup.
Each row asks the same question of both, as their docs answer it on October 30, 2026.
| Question | Drizzle ORM | Prisma ORM |
|---|---|---|
| Where are the tables described? | In TypeScript: db/schema.ts. | In schema.prisma, a file in Prisma's own language (Prisma ORM 7). |
| What does a query look like? | Like SQL: select, from, where, orderBy. | An object of options: findMany({ where, orderBy }). |
| Is there a step after you change the tables? | No: the types come from the schema file itself. | Yes: prisma generate writes the client into your project. |
| How are tables made and changed? | drizzle-kit push, or drizzle-kit generate then drizzle-kit migrate. | prisma migrate dev while you work, prisma migrate deploy for production. |
| Latest on npm, October 30, 2026 | drizzle-orm 0.45.4, with a 1.0 release candidate on its rc tag. | @prisma/client 7.10.1. The prisma command-line tool's latest is 8.0.0-rc.28, a Prisma ORM 8 release candidate. |
Both work the same way with Server Components: a client in a server-only module, queries in your data functions. Drizzle stays close to SQL and needs no generate step. Prisma hides the SQL behind its model API and comes with its own tools, like Prisma Studio for browsing the data. Choose the one whose queries your team reads more easily.
Connection pooling
A connection is an open line between your server and the database. Opening one takes time before the first query can go: the network handshake, often encryption, and signing in. So a server keeps a few connections open and lends one to each query: a connection pool. With a Postgres server, the Pool from pg, a widely used Postgres driver for Node.js (8.23.1), does this, and Drizzle uses it. This setup isn't recorded, because it needs a database server:
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Up to 10 open connections, shared by every query this server instance sends.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
export const db = drizzle({ client: pool });Line 2 imports Pool. Line 5 makes one, with the address from DATABASE_URL and at most 10 connections, which is also pg's default. Line 7 gives it to Drizzle, from drizzle-orm/node-postgres. A query borrows a connection, sends its SQL, and gives the connection back; if all 10 are busy, it waits for one. (PGlite, in the recordings, runs inside the app and needs no connection at all.)
The pool lives inside one server process. Run more processes, and each has its own:
max: 10 can hold 30 connections to the database at once, and each new instance adds 10 more, until the database's limit stops them.Each row is one way the app might run: the pool size, and the connections the database sees.
| Where the app runs | Pool size (max) | Connections the database sees |
|---|---|---|
One server running next start | 10 | Up to 10. |
| 3 serverless instances | 10 each | Up to 30. |
| 50 instances on a busy day | 10 each | Up to 500: five times Postgres's usual default limit of 100. |
| 50 instances, through a pooler | 10 each, to the pooler | Only the pooler's own few connections. |
A serverless host starts more instances when traffic grows, so the count isn't up to you. Two things keep the database safe: a small max for each instance, and a pooler, a server between your app and the database that takes many connections from your instances and passes their queries over a few of its own. PgBouncer is a well-known one, and many hosts give you a pooled connection string beside the direct one. Use the pooled one in the app, and the direct one for migrations.
One client during development
next start loads db/index.ts once in each server process, and every request shares what it made. While you work, Prisma's docs warn, it's different: after you save a change, next dev reloads server code so the page shows your change without a restart, and if db/index.ts runs again that way, it opens another client, with another pool, while the old one stays open. The recordings run next build and next start, so they can't show next dev; this program stands in for it: each call to runDbFile is one run of the file. Press Edit & run.
// Each call stands in for next dev running db/index.ts again.
let opened = 0;
function runDbFile() {
const client = { number: ++opened };
return client;
}
runDbFile();
runDbFile();
runDbFile();
console.log(`Clients opened: ${opened}`);Line 5 makes a new client on every run, and line 12 prints Clients opened: 3. The fix keeps the client on globalThis, JavaScript's global object, which outlives a module that runs again:
// Each call stands in for next dev running db/index.ts again.
let opened = 0;
function runDbFile() {
const client = globalThis.teaClient ?? { number: ++opened };
globalThis.teaClient = client;
return client;
}
runDbFile();
runDbFile();
runDbFile();
console.log(`Clients opened: ${opened}`);Line 5 takes globalThis.teaClient when it's there, and makes a client only when it isn't. Line 6 stores it. Line 13 prints Clients opened: 1. Change line 6 into a comment and run it again: the count goes back to 3.
next dev runs. Without globalThis, each run of db/index.ts opens a new client; with it, every run finds the first one. The two programs print the same counts: 3 and 1.Here is the tea shop's db/index.ts with the same fix, built and visited three times. A production build can only show the production side: one client for the whole server process.
db/index.tsimport { PGlite } from "@electric-sql/pglite";import { drizzle } from "drizzle-orm/pglite";import { seed } from "./seed"; // next dev runs this file again after you save a change. A value on// globalThis survives that, so the app keeps one client.const globalForDb = globalThis as unknown as { teaClient?: PGlite }; function openDatabase() { console.log("Opening the database"); return new PGlite();} const client = globalForDb.teaClient ?? openDatabase();if (process.env.NODE_ENV !== "production") globalForDb.teaClient = client; export const db = drizzle({ client, logger: true }); await client.exec( "CREATE TABLE IF NOT EXISTS teas (id text PRIMARY KEY, name text NOT NULL, price integer NOT NULL, stock integer DEFAULT 0 NOT NULL)",);await seed(db);$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Opening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]Opening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ◐ /└ ○ /_not-found ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyOpening the databaseQuery: insert into "teas" ("id", "name", "price", "stock") values ($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12) on conflict do nothing -- params: ["green", "Green tea", 4, 12, "mint", "Mint tea", 3, 0, "lemon", "Lemon tea", 3, 5]Query: select "id", "name", "price" from "teas" order by "teas"."name" ascQuery: select "id", "name", "price" from "teas" order by "teas"."name" ascQuery: select "id", "name", "price" from "teas" order by "teas"."name" ascLine 7 gives globalThis a type with an optional teaClient. Line 14 uses the client already there, or calls openDatabase on lines 9 to 12. Line 15 stores it, but only outside production: next start sets NODE_ENV to production and runs the file once per process anyway. The Terminal shows that production reuse: one Opening the database after Ready, and one select for each of the three visits. Prisma's docs write the same pattern for its client:
import { PrismaClient } from "../app/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;Line 4 types globalThis, line 7 reuses globalForPrisma.prisma or makes a client with the pg adapter from line 6, and line 9 stores it outside production.
Seeding data
Seeding puts the first rows into a new database: the teas the shop opens with, or made-up data to work with. Here is the shop's seed, from the recorded project:
import type { db } from "./index";
import { teas } from "./schema";
export async function seed(database: typeof db) {
await database
.insert(teas)
.values([
{ id: "green", name: "Green tea", price: 4, stock: 12 },
{ id: "mint", name: "Mint tea", price: 3, stock: 0 },
{ id: "lemon", name: "Lemon tea", price: 3, stock: 5 },
])
.onConflictDoNothing();
}
Line 1 imports only the type of db, so seed takes whatever client db/index.ts makes. Lines 5 to 11 insert three teas in one statement, and line 12, onConflictDoNothing(), skips any tea whose id is already in the table. So the seed is safe to run again: the second time, it changes nothing. The overview's Terminal shows the SQL: insert into "teas" … on conflict do nothing, with the twelve values as parameters.
A database server keeps its rows between runs, so you seed each new or reset database, from your terminal, after its tables exist, and run the seed again when the seed data changes. With Drizzle, npx drizzle-kit push makes the tables from the schema (a drizzle.config.ts file tells it where the schema and the database are), and a small script calls the seed:
// scripts/seed.ts: run it once, with npx tsx scripts/seed.ts
import { db } from "../db";
import { seed } from "../db/seed";
await seed(db);
console.log("Seeded the teas");npx tsx runs a TypeScript file with Node.js. Prisma ORM 7 runs its seed with npx prisma db seed, which runs the command set in prisma.config.ts. For many rows of made-up data, Drizzle has a separate package, drizzle-seed. In the recordings, the database lives in memory and starts empty, so db/index.ts makes the table and seeds it each time a server process starts, on lines 11 to 14:
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { seed } from "./seed";
console.log("Opening the database");
const client = new PGlite();
export const db = drizzle({ client, logger: true });
// In memory, the database starts empty each time the app starts. A database
// server keeps its tables: there, drizzle-kit makes them, and you seed once.
await client.exec(
"CREATE TABLE IF NOT EXISTS teas (id text PRIMARY KEY, name text NOT NULL, price integer NOT NULL, stock integer DEFAULT 0 NOT NULL)",
);
await seed(db);
Setting up the tea shop's database
Every part of the setup runs at its own time: from your terminal or a deploy step, once in each server process, or for every request. Sort them:
npx drizzle-kit migratenpx tsx scripts/seed.tsnpx prisma generate(Prisma ORM 7)new Pool({ connectionString, max: 10 })drizzle({ client: pool })db.select().from(teas)ingetTeas()await connection()
Sort each piece: does it run from a terminal or a deploy step, once for each server process, or for each request?
The pattern behind the sorter: tables and seeds are set up before the app serves anyone, the client opens when a server process loads db/index.ts, and only the queries run for each request.
Common mix-ups
- "You need an ORM to use a database in Next.js." No. The last lesson's page sent plain SQL. An ORM writes the SQL and types the rows for you; the database doesn't care which you use.
- "Each request should open a connection and close it after." No. Opening one takes time, so a pool keeps a few open for every request to share. The recordings opened the database once for all their visits.
- "The
globalThisline is for production." No. It stores the client only outside production, fornext dev, which can run the file again after each save. - "Pooling only matters for big apps." No. On a serverless host, every instance has its own pool, so even a small app on a busy day can run out of connections.
- "The seed runs every time the app starts." Only in these recordings, whose database lives in memory. A database server keeps its rows: you seed each new or reset database.
- "
npm install prismagives me Prisma ORM 7." Not on October 30, 2026: its latest is a Prisma ORM 8 release candidate. Ask forprisma@7if you follow the Prisma 7 setup.
Practice exercises
Most of these exercises are about Next.js projects, which can't run inside this page; the last one runs in the page. Answer first, then use the hints.
This is lib/teas.ts with a where on line 9, and the seed from db/seed.ts. Which teas does the page list?
import "server-only";
import { connection } from "next/server";
import { asc, gt } from "drizzle-orm";
import { db } from "@/db";
import { teas } from "@/db/schema";
export async function getTeas() {
await connection();
return db.select({ id: teas.id, name: teas.name, price: teas.price }).from(teas).where(gt(teas.stock, 0)).orderBy(asc(teas.name));
}
Green tea and Lemon tea, sorted by name. Mint tea has 0 in stock, so where "teas"."stock" > $1 with params: [0] leaves it out. The recorded page lists Green tea: $4 and Lemon tea: $3.
next start served two visits to /. How many Opening the database lines did it print?
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { seed } from "./seed";
console.log("Opening the database");
const client = new PGlite();
export const db = drizzle({ client, logger: true });
// In memory, the database starts empty each time the app starts. A database
// server keeps its tables: there, drizzle-kit makes them, and you seed once.
await client.exec(
"CREATE TABLE IF NOT EXISTS teas (id text PRIMARY KEY, name text NOT NULL, price integer NOT NULL, stock integer DEFAULT 0 NOT NULL)",
);
await seed(db);
Once. next start loads db/index.ts once, and both visits share the client. The recorded output after Ready has one Opening the database, then the seed's insert, then one select per visit.
The shop wants a page of teas to reorder. Change getTeas so it returns only the teas with no stock left. Which tea does the page list?
import "server-only";
import { connection } from "next/server";
import { asc } from "drizzle-orm";
import { db } from "@/db";
import { teas } from "@/db/schema";
export async function getTeas() {
await connection();
return db.select({ id: teas.id, name: teas.name, price: teas.price }).from(teas).orderBy(asc(teas.name));
}
import "server-only";
import { connection } from "next/server";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { teas } from "@/db/schema";
export async function getTeas() {
await connection();
return db.select({ id: teas.id, name: teas.name, price: teas.price }).from(teas).where(eq(teas.stock, 0)).orderBy(asc(teas.name));
}
Mint tea. eq(teas.stock, 0) becomes where "teas"."stock" = $1 with params: [0], as the recorded Terminal shows, and the page lists only Mint tea: $3.
The shop runs on a serverless host. On a busy morning, it runs four instances, each with new Pool({ max: 10 }). How many connections can the database see at most?
40: four instances, each with a pool of at most 10. Each new instance adds its own 10, which is why serverless apps keep max small and connect through a pooler.
This db/index.ts runs the seed twice, as a file that runs again with the same database would, and db/seed.ts has lost its .onConflictDoNothing(). The build stops. Why?
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { seed } from "./seed";
console.log("Opening the database");
const client = new PGlite();
export const db = drizzle({ client, logger: true });
// In memory, the database starts empty each time the app starts. A database
// server keeps its tables: there, drizzle-kit makes them, and you seed once.
await client.exec(
"CREATE TABLE IF NOT EXISTS teas (id text PRIMARY KEY, name text NOT NULL, price integer NOT NULL, stock integer DEFAULT 0 NOT NULL)",
);
await seed(db);
await seed(db);
The second insert uses ids that are already in the table, and id is the primary key. The recorded build stops with duplicate key value violates unique constraint "teas_pkey" and Key (id)=(green) already exists. With .onConflictDoNothing() back in db/seed.ts, the same file builds, and the page lists the three teas once.
Change runDbFile so the three runs share one client, the way db/index.ts does with globalThis. What does the program print then?
// Each call stands in for next dev running db/index.ts again.
let opened = 0;
function runDbFile() {
const client = { number: ++opened };
return client;
}
runDbFile();
runDbFile();
runDbFile();
console.log(`Clients opened: ${opened}`);// Each call stands in for next dev running db/index.ts again.
let opened = 0;
function runDbFile() {
const client = globalThis.teaClient ?? { number: ++opened };
globalThis.teaClient = client;
return client;
}
runDbFile();
runDbFile();
runDbFile();
console.log(`Clients opened: ${opened}`);Clients opened: 1. The first run makes the client and stores it on globalThis. The next two runs find it there, so ++opened runs only once.
Check your understanding
For each question, ask when the code runs: once, once per server process, or for each request.
Question 1 of 7What does an ORM like Drizzle do between your code and the database?
Choose an answer to see the explanation.
Question 2 of 7Which SQL does this version of
lib/teas.tssend?Read the code, then predictTSXimport "server-only"; import { connection } from "next/server"; import { asc, gt } from "drizzle-orm"; import { db } from "@/db"; import { teas } from "@/db/schema"; export async function getTeas() { await connection(); return db.select({ id: teas.id, name: teas.name, price: teas.price }).from(teas).where(gt(teas.stock, 0)).orderBy(asc(teas.name)); }Choose an answer to see the explanation.
Question 3 of 7Why does a server keep a pool of open connections instead of opening one for each query?
Choose an answer to see the explanation.
Question 4 of 750 serverless instances each open a pool with
max: 10. What helps the database most?Choose an answer to see the explanation.
Question 5 of 7Why does
db/index.tskeep its client onglobalThiswhenNODE_ENVisn't"production"?Choose an answer to see the explanation.
Question 6 of 7What does
.onConflictDoNothing()on line 12 do when the seed runs a second time?Read the code, then predictTSXimport type { db } from "./index"; import { teas } from "./schema"; export async function seed(database: typeof db) { await database .insert(teas) .values([ { id: "green", name: "Green tea", price: 4, stock: 12 }, { id: "mint", name: "Mint tea", price: 3, stock: 0 }, { id: "lemon", name: "Lemon tea", price: 3, stock: 5 }, ]) .onConflictDoNothing(); }Choose an answer to see the explanation.
Question 7 of 7On October 30, 2026, what does
npm install prismainstall?Choose an answer to see the explanation.
Key takeaways
- Postgres is a common database for a Next.js app. Keep its connection string in a server environment variable, like
DATABASE_URL. - An ORM describes your tables in code and writes the SQL, with values as parameters. Drizzle reads like SQL; Prisma uses models and a generated client.
- Each server process opens one client, with a pool of a few connections. On serverless hosts, pools add up: keep them small and use a pooler.
- While you work,
next devcan rundb/index.tsagain. Keep the client onglobalThisoutside production. - Make tables with the ORM's migration tool, and seed each new or reset database with a seed that's safe to run twice:
onConflictDoNothing(). - Versions move fast: on October 30, 2026, Drizzle's docs install a 1.0 release candidate and
npm install prismagives a Prisma ORM 8 release candidate. Pin what you follow.
Remember the one-liner.
Connect once per process, pool a few connections, let the ORM write the SQL, and seed with a script you can run twice.
Coming next: Parallel and sequential fetching. It finds request waterfalls between components and removes them, with Promise.all and React's cache().