Type something to search...
Connecting Next.js to a Database with Prisma ORM

Connecting Next.js to a Database with Prisma ORM

The App Router changed how Next.js apps talk to databases. Server Components run on the server, so a page can query the database directly with no API route in between, and Server Actions let forms write data the same way. What you need is a database client that's type-safe, easy to set up, and behaves well in development and in serverless deployments.

Prisma is one of the most popular choices for that. You describe your data model in a schema file, Prisma generates a fully typed client from it, and its migration tool keeps your database in sync with the schema. Prisma 7 also made the client lighter: there's no separate query engine binary anymore, and connections go through standard JavaScript database drivers.

In this post I'll connect a Next.js 16 app to PostgreSQL with Prisma 7. I'll cover installation, the schema and prisma.config.ts, migrations, creating a client that survives hot reloading, reading data in Server Components, writing data with Server Actions, caching query results, seeding, and the details that matter in production.

Installing Prisma

This guide uses PostgreSQL. Any Postgres works: a local install, Docker, or a hosted provider. Install the Prisma CLI as a dev dependency and the client plus the Postgres driver adapter as regular dependencies:

npm install -D prisma@7 tsx dotenv
npm install @prisma/client@7 @prisma/adapter-pg@7

I pin the major version here because Prisma publishes release candidates for the next major under its default tag from time to time; pinning keeps you on the stable 7.x line. tsx runs TypeScript scripts such as the seed file, and dotenv loads environment variables for the Prisma CLI.

Add your connection string to .env:

# .env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp?schema=public"

Make sure .env is in .gitignore. Next.js loads .env automatically for your app, but the Prisma CLI runs outside Next.js, which is why dotenv is needed for it. The environment variables post covers how Next.js handles .env files.

The Prisma Config File

Prisma 7 moves project configuration into a TypeScript file at the project root:

// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
    seed: "tsx prisma/seed.ts",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
  • import "dotenv/config" loads .env so env("DATABASE_URL") has a value when you run CLI commands.
  • schema points at your schema file.
  • migrations.path is where migration SQL files are stored, and migrations.seed is the command prisma db seed runs.
  • datasource.url is the connection the CLI uses for migrations and introspection. env() throws a clear error if the variable is missing, which beats a confusing connection failure.

Defining the Schema

The schema describes your models and how the client is generated:

// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        String   @id @default(cuid())
  slug      String   @unique
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([authorId])
  @@index([published, createdAt])
}

Key parts:

  • provider = "prisma-client" is the current generator. It writes plain TypeScript into the folder you choose with output, rather than into node_modules. The output path is required and is relative to the schema file.
  • The datasource block only names the database type. The URL lives in prisma.config.ts for the CLI and in the driver adapter for your app.
  • @relation defines the foreign key from Post.authorId to User.id. onDelete: Cascade deletes a user's posts when the user is deleted.
  • @updatedAt sets the timestamp automatically on every update.
  • @@index adds database indexes for the queries you'll run most: posts by author, and published posts sorted by date.

Add the generated folder to .gitignore, since it's rebuilt from the schema:

# .gitignore
/generated/prisma

Running Migrations and Generating the Client

Create the first migration and apply it to your development database:

npx prisma migrate dev --name init
npx prisma generate

migrate dev compares the schema to the database, writes a SQL migration into prisma/migrations, and applies it. generate builds the typed client into generated/prisma. Run generate explicitly after schema changes rather than relying on another command to do it, so the client always matches the schema you just migrated.

Commit the prisma/migrations folder. Those SQL files are the history your production database is built from.

To make sure the client exists wherever your app is installed (including CI and hosting platforms), add a postinstall script:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "postinstall": "prisma generate"
  }
}

Creating the Prisma Client

In Prisma 7, the client connects through a driver adapter. For Postgres that's PrismaPg, which uses the pg driver under the hood:

// lib/db.ts
import "server-only";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";

function createPrismaClient() {
  const adapter = new PrismaPg({
    connectionString: process.env.DATABASE_URL!,
  });

  return new PrismaClient({ adapter });
}

const globalForPrisma = globalThis as unknown as {
  prisma: ReturnType<typeof createPrismaClient> | undefined;
};

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Why the globalThis dance? In development, Next.js reloads modules when you save a file. Without caching, each reload would create a new PrismaClient with its own connection pool, and after a few dozen edits you'd hit the database's connection limit. Storing the instance on globalThis (which survives module reloads) means one client for the whole dev session. In production, modules load once, so the normal module-level instance is enough.

import "server-only" makes the build fail if a Client Component ever imports this file, which keeps your connection string and database access off the client. The server-only package post explains how that works.

The @/generated/prisma/client import assumes the default @/* alias maps to your project root. If your code lives in src/, set the generator output to ../src/generated/prisma so the alias resolves.

Reading Data in Server Components

Server Components can be async and query the database directly:

// app/blog/page.tsx
import Link from "next/link";
import { prisma } from "@/lib/db";

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    select: {
      id: true,
      slug: true,
      title: true,
      createdAt: true,
      author: { select: { name: true } },
    },
    take: 20,
  });

  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 text-3xl font-bold">Blog</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id}>
            <Link
              href={`/blog/${post.slug}`}
              className="text-lg font-medium hover:underline"
            >
              {post.title}
            </Link>
            <p className="text-sm text-gray-500">
              {post.author.name ?? "Anonymous"} ·{" "}
              {post.createdAt.toLocaleDateString("en-US", {
                dateStyle: "medium",
              })}
            </p>
          </li>
        ))}
      </ul>
    </main>
  );
}

There's no fetch, no API route, and no loading state to manage by hand. The query runs on the server during rendering and the HTML arrives with the data in it.

The select clause is worth the extra lines. It fetches only the columns this page uses, and the result type reflects exactly that: post.content doesn't exist on the result, so TypeScript stops you from using data you didn't load. That matters even more when you pass results to Client Components, since anything you pass gets serialized to the browser. Selecting only what the UI needs is an easy way to avoid leaking fields like email addresses.

A Dynamic Route

For a single post, read the slug from params (a promise in Next.js 16) and use findUnique:

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const post = await prisma.post.findUnique({
    where: { slug },
    include: { author: { select: { name: true } } },
  });

  if (!post || !post.published) notFound();

  return (
    <article className="prose mx-auto p-8">
      <h1>{post.title}</h1>
      <p>By {post.author.name ?? "Anonymous"}</p>
      <div>{post.content}</div>
    </article>
  );
}

findUnique only accepts fields marked @id or @unique, which is why slug has @unique in the schema. notFound() renders the nearest not-found.tsx, so missing and unpublished posts get a proper 404.

Avoiding Waterfalls

When a page needs several independent queries, start them together:

// app/dashboard/page.tsx (excerpt)
const [postCount, draftCount, recentPosts] = await Promise.all([
  prisma.post.count({ where: { published: true } }),
  prisma.post.count({ where: { published: false } }),
  prisma.post.findMany({ orderBy: { createdAt: "desc" }, take: 5 }),
]);

Awaiting them one by one would make each query wait for the previous one. For more on this, see Fetching Data in Parallel vs Sequentially in Next.js.

Writing Data with Server Actions

Server Actions are the natural place for inserts, updates, and deletes. Here's an action that creates a post:

// app/blog/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";

export type ActionState = { error?: string };

function slugify(value: string) {
  return value
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}

export async function createPost(
  _prev: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const title = String(formData.get("title") ?? "").trim();
  const content = String(formData.get("content") ?? "").trim();

  if (title.length < 3) {
    return { error: "Title must be at least 3 characters." };
  }

  // Replace with the signed-in user's ID from your auth library
  const authorId = "user_123";
  const slug = slugify(title);

  try {
    await prisma.post.create({
      data: { title, slug, content, published: true, authorId },
    });
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === "P2002"
    ) {
      return { error: "A post with this title already exists." };
    }
    throw error;
  }

  revalidatePath("/blog");
  redirect(`/blog/${slug}`);
}

What's happening:

  • Validation first. Server Actions are public endpoints, so never trust form input. A schema library like Zod makes this cleaner; see Form Validation in Next.js with Zod and Server Actions.
  • Authorization. The hard-coded authorId is a placeholder. In a real app, read the user from your session and check they're allowed to create posts before touching the database.
  • P2002 is Prisma's error code for a unique constraint violation, here a duplicate slug. Catching it lets you return a friendly message instead of a 500.
  • revalidatePath("/blog") clears cached output for the blog index so the new post appears.
  • redirect sends the user to the new post. It's called outside the try block because redirect works by throwing a special error that Next.js needs to receive.

The form that uses it:

// app/blog/new/page.tsx
"use client";

import { useActionState } from "react";
import { createPost, type ActionState } from "../actions";

const initialState: ActionState = {};

export default function NewPostPage() {
  const [state, formAction, pending] = useActionState(createPost, initialState);

  return (
    <form action={formAction} className="mx-auto max-w-xl space-y-4 p-8">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          Title
        </label>
        <input
          id="title"
          name="title"
          required
          className="mt-1 w-full rounded border px-3 py-2"
        />
      </div>
      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          Content
        </label>
        <textarea
          id="content"
          name="content"
          rows={8}
          className="mt-1 w-full rounded border px-3 py-2"
        />
      </div>
      {state.error && (
        <p role="alert" className="text-sm text-red-600">
          {state.error}
        </p>
      )}
      <button
        type="submit"
        disabled={pending}
        className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        {pending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

Transactions

When several writes must succeed or fail together, wrap them in a transaction:

// app/blog/actions.ts (excerpt)
export async function transferPosts(fromUserId: string, toUserId: string) {
  await prisma.$transaction(async (tx) => {
    const target = await tx.user.findUniqueOrThrow({ where: { id: toUserId } });

    await tx.post.updateMany({
      where: { authorId: fromUserId },
      data: { authorId: target.id },
    });

    await tx.user.delete({ where: { id: fromUserId } });
  });

  revalidatePath("/blog");
}

If any query inside the callback throws, Prisma rolls back all of them. Use the tx client inside the callback, not the global prisma, or those queries won't be part of the transaction.

Caching Query Results

Database queries aren't cached by default. Without Cache Components, a page that queries the database is rendered dynamically on each request unless you opt into static rendering or revalidation for that route. That's often fine, since Postgres is fast, but for content that changes rarely you can cache.

With cacheComponents: true in next.config.ts, you cache at the function level with "use cache", tag the result, and invalidate the tag when data changes:

// lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { prisma } from "@/lib/db";

export async function getPublishedPosts() {
  "use cache";
  cacheTag("posts");
  cacheLife("hours");

  return prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    select: { id: true, slug: true, title: true, createdAt: true },
  });
}
// app/blog/actions.ts (excerpt)
import { updateTag } from "next/cache";

// after a successful prisma.post.create(...)
updateTag("posts");

updateTag expires the tag immediately, so the user who just published sees their post on the next render. It can only be called from Server Actions. From a Route Handler or webhook, use revalidateTag("posts", "max") instead, which takes a cache profile as its second argument in Next.js 16. The use cache guide and the on-demand revalidation post go deeper on these APIs.

One detail: cached return values must be serializable. Prisma results are plain objects with Date values, which are supported, so query results can be cached directly.

Seeding the Database

A seed script fills your development database with sample data:

// prisma/seed.ts
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";

const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});

async function main() {
  const user = await prisma.user.upsert({
    where: { email: "maria@example.com" },
    update: {},
    create: { email: "maria@example.com", name: "Maria" },
  });

  await prisma.post.upsert({
    where: { slug: "hello-world" },
    update: {},
    create: {
      slug: "hello-world",
      title: "Hello World",
      content: "The first post.",
      published: true,
      authorId: user.id,
    },
  });
}

main()
  .catch((error) => {
    console.error(error);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

Run it with:

npx prisma db seed

Using upsert makes the script safe to run repeatedly. The seed creates its own client with a relative import, because it runs with tsx outside Next.js, where the @/ alias and the server-only package don't apply.

Prisma Studio

For browsing and editing data during development, Prisma ships a GUI:

npx prisma studio

It opens in your browser with a table view of every model, which is handy for checking what a Server Action actually wrote.

Production Checklist

A few things to get right before deploying:

  • Apply migrations in your deploy pipeline with npx prisma migrate deploy. Unlike migrate dev, it only applies existing migration files, never generates new ones, and never resets data. Run it before the new version of your app starts serving traffic.
  • Generate the client during install or build. The postinstall script handles this on most platforms. If your build skips install scripts, run prisma generate before next build.
  • Watch connection counts in serverless. Each serverless function instance has its own pool. Under load, many instances can exhaust your database's connection limit. Use a connection pooler (PgBouncer, or your provider's pooled connection string) and keep the pool small per instance, for example with new PrismaPg({ connectionString, max: 5 }).
  • Use the Node.js runtime. Server Components and Server Actions run on Node.js by default, which is what the pg driver needs. The Edge vs Node.js runtime post explains the difference.
  • Keep secrets on the server. DATABASE_URL must never have a NEXT_PUBLIC_ prefix, and lib/db.ts should keep its server-only import.

Common Errors

ErrorCauseFix
Cannot find module @/generated/prisma/clientClient not generatedRun npx prisma generate
env("DATABASE_URL") is missingCLI can't see .envAdd import "dotenv/config" to prisma.config.ts
Too many connections in developmentNew client on every hot reloadUse the globalThis singleton
Types don't include a new fieldClient is staleRun prisma generate after editing the schema
P2002 unique constraint failedDuplicate value in a @unique fieldCatch it and return a friendly error
Build fails importing lib/db.ts in a client fileserver-only guardMove the query to a Server Component or Action

Conclusion

Connecting Next.js to a database with Prisma 7 takes a schema, a prisma.config.ts, a driver adapter, and a small client module. Define your models, run migrate dev and generate, and export a single PrismaClient cached on globalThis so development reloads don't exhaust connections.

From there, the App Router does the rest: Server Components query the database directly with fully typed results, Server Actions handle writes followed by revalidatePath or updateTag, and "use cache" with tags lets you cache rarely changing queries. In production, run migrate deploy, generate the client at install time, and put a connection pooler in front of your database if you deploy to serverless.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading