AI Reading
Quick summary of this article
Connect Next.js 16 to PostgreSQL with Prisma ORM 7: the new prisma-client generator, driver adapters, a server-only client singleton, migrations, seeding and type-safe queries.
- Prisma 7 ships as ESM and requires
"type": "module", Node.js 20.19+ and TypeScript 5.4+. - The generator is now
prisma-clientwith a mandatoryoutputpath — the client no longer lands innode_modules. - Every database needs a driver adapter in v7; PostgreSQL uses
@prisma/adapter-pg. - Always create one Prisma Client instance per process, guarded by
globalThisso hot reload does not exhaust connections.
Complete Next.js 16 Series — Module 9 of 12
Untyped database access is the most common source of production bugs in Next.js apps. Prisma gives you generated types that flow from your schema into your components and Server Actions, so a renamed column becomes a compile error instead of a 500.
Installing Prisma 7
Prisma 7 is a significant rewrite. Verify your versions first — the minimums are Node.js 20.19.0 and TypeScript 5.4.0.
npm install prisma@7.10.1 --save-dev
npm install @prisma/client@7.10.1 @prisma/adapter-pg
npm install dotenv
Prisma 7 ships as an ES module, so your package.json needs the module type:
{
"type": "module"
}
And tsconfig.json should target modern module output:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2023",
"strict": true,
"esModuleInterop": true
}
}
The Schema
prisma/schema.prisma:
datasource db {
provider = "postgresql"
}
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
excerpt String
body String
published Boolean @default(false)
publishedAt DateTime?
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, publishedAt])
}
enum Role {
USER
EDITOR
ADMIN
}
Two v7 changes to notice:
- The generator is
prisma-client(notprisma-client-js), andoutputis required. - The datasource no longer holds
url— that moved toprisma.config.ts.
Configuration and Environment
Prisma 7 does not load .env automatically. Wire it up in prisma.config.ts:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: env("DATABASE_URL"),
},
migrations: {
path: "prisma/migrations",
},
});
And .env.local:
DATABASE_URL="postgresql://app_user:secret@localhost:5432/nextjs16?schema=public"
Use a dedicated database user with only the privileges the app needs. Never reuse the
postgressuperuser for an application connection string.
The Client Singleton
Create src/lib/db.ts:
import "server-only";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5000,
});
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
Why the singleton matters: in development, Fast Refresh re-evaluates modules on every save. Without the globalThis guard you open a new pool each time and hit PostgreSQL’s too many clients error within minutes.
max and connectionTimeoutMillis are passed to the pg driver. Prisma 7 uses the driver’s own pool, and pg has no connect timeout by default — set one explicitly or hung connections will pile up.
Migrations
npx prisma migrate dev --name init
npx prisma generate
migrate devcompares your schema to the database, writes a SQL migration and applies it. Development only.migrate deployapplies pending migrations without prompting. Use this in CI and production.prisma generateregenerates the typed client intosrc/generated/prisma.
Commit the prisma/migrations/ folder. Never edit an applied migration — add a new one.
Scripts worth adding:
{
"scripts": {
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"postinstall": "prisma generate"
}
}
postinstall is what makes deploys work — the generated client is not committed, so every install regenerates it.
Querying in Server Components
// src/app/blog/page.tsx
import { db } from "@/lib/db";
export default async function BlogIndex() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { publishedAt: "desc" },
include: { author: { select: { name: true } } },
take: 20,
});
return (
<ul className="divide-y">
{posts.map((post) => (
<li key={post.id} className="py-4">
<h2 className="font-semibold">{post.title}</h2>
<p className="text-sm text-neutral-500">
{post.author.name} · {post.publishedAt?.toLocaleDateString()}
</p>
</li>
))}
</ul>
);
}
post.author.name is fully typed. Rename author in the schema and this file stops compiling — with the exact location. No runtime surprises.
Writing in a Server Action
// src/app/admin/posts/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
const postSchema = z.object({
title: z.string().min(4).max(160),
slug: z.string().regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers and hyphens only"),
body: z.string().min(50),
});
export async function createPost(formData: FormData) {
const session = await auth();
if (session?.user?.role !== "ADMIN" && session?.user?.role !== "EDITOR") {
throw new Error("Forbidden");
}
const parsed = postSchema.safeParse({
title: formData.get("title"),
slug: formData.get("slug"),
body: formData.get("body"),
});
if (!parsed.success) {
return { ok: false, errors: z.flattenError(parsed.error).fieldErrors };
}
await db.post.create({
data: { ...parsed.data, authorId: session.user.id, published: true, publishedAt: new Date() },
});
revalidateTag("posts", "max");
return { ok: true };
}
Note the shape of a good mutation: authorise → validate → write → invalidate.
Seeding
prisma/seed.ts:
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const db = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function main() {
const admin = await db.user.upsert({
where: { email: "admin@example.com" },
update: {},
create: { email: "admin@example.com", name: "Admin", role: "ADMIN" },
});
await db.post.upsert({
where: { slug: "hello-world" },
update: {},
create: {
slug: "hello-world",
title: "Hello world",
excerpt: "The first post.",
body: "Seeded content for local development and demos.",
published: true,
publishedAt: new Date(),
authorId: admin.id,
},
});
}
main()
.then(() => db.$disconnect())
.catch(async (e) => {
console.error(e);
await db.$disconnect();
process.exit(1);
});
Dealing with N+1 Queries
Prisma’s include runs a small number of batched queries, but deep nesting still costs. When a list page only needs counts, aggregate in the database:
const authors = await db.user.findMany({
select: {
id: true,
name: true,
_count: { select: { posts: true } },
},
});
When you need raw speed, drop to typed SQL:
const rows = await db.$queryRaw<{ month: string; total: bigint }[]>`
SELECT to_char(date_trunc('month', "publishedAt"), 'YYYY-MM') AS month,
count(*) AS total
FROM "Post"
WHERE published = true
GROUP BY 1
ORDER BY 1 DESC
`;
Tagged templates are parameterised — interpolated values never become SQL injection vectors. Never build SQL with string concatenation.
Module 9 Recap
- Prisma 7 is ESM, needs Node 20.19+, and uses the
prisma-clientgenerator with a requiredoutput. - Configure the datasource in
prisma.config.tsand load.envyourself. - Every database needs a driver adapter —
@prisma/adapter-pgfor PostgreSQL. - Use a
globalThissingleton with explicit pool limits. - Authorise, validate, write, invalidate — in that order.
- Commit migrations, run
prisma generateon install.
Next up — Module 10: authentication and authorisation with Auth.js v5 and Next.js 16 proxy.ts.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 8: Route Handlers & APIs
- Next: Module 10: Authentication with Auth.js
