Module 9: Database Layer with Prisma 7 & PostgreSQL in Next.js 16

Module 9: Database Layer with Prisma 7 & PostgreSQL in Next.js 16

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-client with a mandatory output path — the client no longer lands in node_modules.
  • Every database needs a driver adapter in v7; PostgreSQL uses @prisma/adapter-pg.
  • Always create one Prisma Client instance per process, guarded by globalThis so 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 (not prisma-client-js), and output is required.
  • The datasource no longer holds url — that moved to prisma.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 postgres superuser 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 dev compares your schema to the database, writes a SQL migration and applies it. Development only.
  • migrate deploy applies pending migrations without prompting. Use this in CI and production.
  • prisma generate regenerates the typed client into src/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-client generator with a required output.
  • Configure the datasource in prisma.config.ts and load .env yourself.
  • Every database needs a driver adapter — @prisma/adapter-pg for PostgreSQL.
  • Use a globalThis singleton with explicit pool limits.
  • Authorise, validate, write, invalidate — in that order.
  • Commit migrations, run prisma generate on install.

Next up — Module 10: authentication and authorisation with Auth.js v5 and Next.js 16 proxy.ts.

Leave a Reply

Your email address will not be published. Required fields are marked *