Type something to search...
How to Use Prisma ORM with Next.js?

How to Use Prisma ORM with Next.js?

Sooner or later most Next.js applications need a real database. Writing raw SQL strings in Server Components works, but it gives you no type safety, no migrations, and no protection against a renamed column breaking production. Prisma ORM solves those problems with a declarative schema file, generated TypeScript types for every model, a migration system, and a query API that autocompletes as you type. Combined with the App Router, where Server Components can query the database directly and Server Actions handle writes, Prisma gives you a full-stack data layer without building a separate API.

This article covers using Prisma with Next.js from setup to production: installing Prisma and connecting to PostgreSQL, defining models and running migrations, creating a Prisma Client instance that survives hot reloading, reading data in Server Components, writing data with Server Actions, handling relations, transactions, and errors, controlling caching, seeding, and the deployment details that trip people up. The examples use Prisma ORM 7 and Next.js 16, with notes where older versions differ.

Why Prisma Fits the App Router

In the Pages Router, database access had to live in getServerSideProps, getStaticProps, or API routes. The App Router removes that indirection. Server Components run only on the server, so they can call the database directly, and their code never reaches the browser.

TaskWhere it runs in the App RouterPrisma role
Reading data for a pageAsync Server ComponentfindMany, findUnique, count
Creating, updating, deletingServer Actioncreate, update, delete, $transaction
Public JSON API or webhooksRoute Handler (route.ts)Any query
Schema changesCLI during development and deploymentprisma migrate dev / deploy

Prisma supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, CockroachDB, and MongoDB. If you are still deciding which database to use, see how to use Next.js with MongoDB or other databases.

Installing Prisma

Start in an existing App Router project. Install the Prisma CLI as a dev dependency, the client and PostgreSQL driver adapter as regular dependencies, and dotenv for loading environment variables in the Prisma config. Pin the Prisma packages to major version 7 so they match each other and this guide:

# Terminal
npm install -D prisma@7
npm install @prisma/client@7 @prisma/adapter-pg@7 dotenv
npx prisma init

A note on versions: Prisma ORM 8 is a ground-up TypeScript rewrite with a different CLI and project layout, and an unpinned npm install prisma now installs it. Prisma ORM 7 remains the stable, widely deployed schema-first release, and everything below targets it. If you are starting fresh on Prisma ORM 8, follow its own Next.js guide instead, because commands such as prisma init and the generated file paths differ.

prisma init creates a prisma/schema.prisma file, a prisma.config.ts file at the project root, and adds a DATABASE_URL placeholder to .env. Set it to your database connection string:

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

Never commit .env. Add it to .gitignore and configure the same variable in your hosting provider. For more on how Next.js and its tools read these files, see how to use environment variables in Next.js.

The Prisma Config File

In Prisma ORM 7, connection details and CLI settings live in prisma.config.ts rather than in the schema:

// 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"),
  },
});

The import "dotenv/config" line matters: the Prisma CLI no longer loads .env files automatically, so without it, commands like prisma migrate dev cannot see DATABASE_URL.

Defining the Schema

The schema describes your models, their fields, and relations. Here is a small blog schema with users and posts:

// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/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())
  title       String
  slug        String   @unique
  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])
}

What each part does:

  • generator client tells Prisma to generate a TypeScript client. The prisma-client generator writes the client into your source tree at the output path, so it is bundled and type-checked like the rest of your code. Add src/generated to .gitignore, because it is regenerated on demand.
  • datasource db sets the database type. The URL comes from prisma.config.ts.
  • @id @default(cuid()) creates string IDs that are safe to expose in URLs.
  • @relation connects each post to its author; onDelete: Cascade deletes a user's posts when the user is deleted.
  • @updatedAt is set automatically on every update.
  • @@index adds database indexes for the columns you filter and sort on most.

Running Migrations and Generating the Client

Create the tables and generate the client:

# Terminal
npx prisma migrate dev --name init
npx prisma generate

migrate dev compares the schema with the database, writes a SQL migration file to prisma/migrations, and applies it. Commit those migration files; they are the history of your database. prisma generate writes the typed client to src/generated/prisma. Run it again whenever the schema changes. Adding it to postinstall and your build script keeps the client in sync on every machine:

{
  "scripts": {
    "postinstall": "prisma generate",
    "build": "prisma generate && next build"
  }
}

To browse your data in a GUI, run npx prisma studio.

Creating a Single Prisma Client Instance

This is the step most often done wrong. In development, Next.js hot-reloads modules when you save a file. If you create a new PrismaClient at module scope, every reload creates another client with its own connection pool, and after a few minutes of editing you hit "too many connections" errors. The fix is to store the client on globalThis in development so it survives reloads:

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

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

function createPrismaClient() {
  const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
  return new PrismaClient({
    adapter,
    log:
      process.env.NODE_ENV === "development"
        ? ["query", "warn", "error"]
        : ["error"],
  });
}

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

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

Details worth understanding:

  • Driver adapter. Prisma ORM 7 connects through a JavaScript driver adapter, here @prisma/adapter-pg for PostgreSQL. Other databases have their own adapters, such as @prisma/adapter-mariadb and @prisma/adapter-better-sqlite3.
  • server-only. Importing this module from a Client Component now fails the build instead of silently trying to bundle a database client for the browser. Install it with npm install server-only.
  • Query logging in development. Seeing the SQL each query produces is the fastest way to spot N+1 queries.
  • In production, the module is evaluated once per server instance, so the global cache is not needed.

On Prisma ORM 6 and earlier, the setup is slightly different: the generator is usually prisma-client-js, the url = env("DATABASE_URL") line goes inside the datasource block of the schema, you import PrismaClient from @prisma/client, and no adapter is required. The global singleton pattern is the same.

Reading Data in Server Components

With the client in place, any async Server Component can query the database. There is no API route or useEffect involved:

// src/app/posts/page.tsx
import Link from "next/link";
import { connection } from "next/server";
import { prisma } from "@/lib/prisma";

export default async function PostsPage() {
  await connection(); // render on every request, not at build time

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

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

Two things deserve attention:

  1. select keeps queries lean. Fetch only the columns the page renders. It also keeps sensitive fields, such as password hashes, from accidentally reaching a Client Component as props.
  2. await connection() tells Next.js this page depends on request time. Prisma queries are not fetch calls, so Next.js cannot see them. Without a dynamic signal, a page with no params, cookies, or headers may be prerendered once during next build, which means the build needs database access and the list never updates until the next deploy. If static output is what you want, keep it static and revalidate on writes instead, as shown below.

A Dynamic Detail Page

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

type Props = { params: Promise<{ slug: string }> };

export default async function PostPage({ params }: Props) {
  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 className="whitespace-pre-wrap">{post.content}</div>
    </article>
  );
}

params is a Promise in Next.js 15 and 16, so it must be awaited. findUnique works on the slug field because it is marked @unique in the schema.

Deduplicating Queries with React cache

If both generateMetadata and the page need the same post, wrap the query in React's cache so it runs once per request:

// src/lib/queries.ts
import "server-only";
import { cache } from "react";
import { prisma } from "@/lib/prisma";

export const getPostBySlug = cache(async (slug: string) => {
  return prisma.post.findUnique({
    where: { slug },
    include: { author: { select: { name: true } } },
  });
});

Call getPostBySlug(slug) from both places. The second call in the same request returns the memoized result instead of hitting the database again.

Writing Data with Server Actions

Server Actions are async functions that run on the server and can be called from forms and Client Components. They are the natural place for Prisma writes:

// src/app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth";

const PostSchema = z.object({
  title: z
    .string()
    .trim()
    .min(3, "Title must be at least 3 characters")
    .max(120),
  content: z.string().trim().min(10, "Content is too short"),
});

export type FormState = { error?: string };

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

export async function createPost(
  _prev: FormState,
  formData: FormData,
): Promise<FormState> {
  const user = await getCurrentUser();
  if (!user) return { error: "You must be signed in." };

  const parsed = PostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });
  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }

  let slug: string;
  try {
    const post = await prisma.post.create({
      data: {
        title: parsed.data.title,
        slug: slugify(parsed.data.title),
        content: parsed.data.content,
        published: true,
        author: { connect: { id: user.id } },
      },
    });
    slug = post.slug;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === "P2002"
    ) {
      return { error: "A post with this title already exists." };
    }
    throw error;
  }

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

export async function deletePost(postId: string) {
  const user = await getCurrentUser();
  if (!user) throw new Error("Unauthorized");

  await prisma.post.delete({
    where: { id: postId, authorId: user.id },
  });

  revalidatePath("/posts");
}

The important practices in this file:

  • Authenticate and authorize inside every action. A Server Action is a public HTTP endpoint. Hiding the button in the UI does not protect it. The deletePost query includes authorId in the where clause, so users can only delete their own posts. getCurrentUser stands in for whatever your auth library provides.
  • Validate input with a schema. FormData values are untrusted strings. zod turns them into typed, validated data.
  • Handle known Prisma errors. P2002 is the unique constraint violation code; here it means the slug already exists. Unknown errors are rethrown so they reach your error boundary.
  • redirect is called outside the try block. It works by throwing a special error, which a catch would swallow.
  • revalidatePath clears cached output for the posts list so the new post appears immediately.

Now a form that calls the action and shows errors, using React's useActionState:

// src/app/posts/new/NewPostForm.tsx
"use client";

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

const initialState: FormState = {};

export function NewPostForm() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState,
  );

  return (
    <form action={formAction} className="space-y-4">
      <input
        name="title"
        placeholder="Title"
        required
        className="w-full rounded border p-2"
      />
      <textarea
        name="content"
        placeholder="Write something..."
        rows={8}
        required
        className="w-full rounded border p-2"
      />
      {state.error && <p className="text-sm text-red-600">{state.error}</p>}
      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-black px-4 py-2 text-white"
      >
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

The form works even before JavaScript loads, because the action posts directly to the Server Action. For a deeper look at this pattern, see how to use Server Actions to handle mutations in Next.js.

Relations, Transactions, and Pagination

Nested Writes

Prisma can create related records in one call. This creates a user and their first post together:

// src/lib/onboarding.ts
import "server-only";
import { prisma } from "@/lib/prisma";

export async function createUserWithWelcomePost(email: string, name: string) {
  return prisma.user.create({
    data: {
      email,
      name,
      posts: {
        create: {
          title: "Hello, world",
          slug: `hello-${Date.now()}`,
          content: "My first post.",
        },
      },
    },
    include: { posts: true },
  });
}

Transactions

When several writes must succeed or fail together, use an interactive transaction:

// src/lib/transfer.ts
import "server-only";
import { prisma } from "@/lib/prisma";

export async function transferPosts(fromUserId: string, toUserId: string) {
  return prisma.$transaction(async (tx) => {
    const target = await tx.user.findUnique({ where: { id: toUserId } });
    if (!target) throw new Error("Target user not found");

    const { count } = await tx.post.updateMany({
      where: { authorId: fromUserId },
      data: { authorId: toUserId },
    });

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

If any step throws, every change in the callback is rolled back.

Cursor Pagination

For long lists, cursor-based pagination stays fast regardless of page depth, unlike large skip offsets:

// src/lib/queries.ts (excerpt)
export async function getPostsPage(cursor?: string, pageSize = 10) {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: [{ createdAt: "desc" }, { id: "desc" }],
    take: pageSize + 1,
    ...(cursor && { cursor: { id: cursor }, skip: 1 }),
  });

  const hasMore = posts.length > pageSize;
  const items = hasMore ? posts.slice(0, pageSize) : posts;
  return { items, nextCursor: hasMore ? items[items.length - 1].id : null };
}

Fetching one extra row tells you whether there is a next page without a separate count query.

Seeding the Database

A seed script gives every developer the same starting data. The seed command was registered in prisma.config.ts earlier:

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

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

async function main() {
  await prisma.user.upsert({
    where: { email: "demo@example.com" },
    update: {},
    create: {
      email: "demo@example.com",
      name: "Demo User",
      posts: {
        create: [
          {
            title: "Getting Started",
            slug: "getting-started",
            content: "Welcome!",
            published: true,
          },
          {
            title: "Draft Ideas",
            slug: "draft-ideas",
            content: "Work in progress.",
            published: false,
          },
        ],
      },
    },
  });
}

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

Install tsx with npm install -D tsx, then run npx prisma db seed. Using upsert makes the script safe to run repeatedly.

Deploying Next.js with Prisma

A few production rules prevent most deployment issues:

  1. Run migrations with prisma migrate deploy, never migrate dev, in production. deploy applies pending migration files without prompting or resetting data. Run it as a release step or in CI before the new version starts.
  2. Generate the client during the build. The postinstall and build scripts shown earlier handle this, so a missing generated client never reaches production.
  3. Use connection pooling on serverless platforms. Each serverless function instance opens its own pool. Under load, that can exhaust your database's connection limit. Use your provider's pooled connection string, a pooler such as PgBouncer, or Prisma Postgres, and keep the pool size per instance small.
  4. Keep Prisma on the Node.js runtime unless you have confirmed that your adapter and database driver support the Edge runtime.
  5. Include the generated client in Docker images. If you deploy with output: "standalone", run prisma generate in the build stage so the client is bundled into the output. Self-hosting details are in how to use Docker to containerize a Next.js app.

Common Problems and Fixes

  • "Too many connections" in development. A new PrismaClient is created on every hot reload. Use the globalThis singleton shown above and import the client from a single module.
  • "Cannot find module '@/generated/prisma/client'". The client has not been generated. Run npx prisma generate, and add it to postinstall and your build script.
  • DATABASE_URL not found when running the CLI. Prisma ORM 7 does not load .env automatically. Add import "dotenv/config" at the top of prisma.config.ts.
  • "Module not found: Can't resolve 'fs'" or similar in the browser. A Client Component imports the Prisma module. Keep database access in Server Components and Server Actions, and add import "server-only".
  • The build fails because it cannot reach the database. A page was prerendered at build time and ran its query. Call await connection() in the page, or make sure the build environment can reach the database.
  • New records do not show up. The page output is cached. Call revalidatePath or revalidateTag after the write.
  • Dates passed to Client Components cause errors. Props must be serializable. Convert Date objects with toISOString() before passing them to Client Components.

Prisma with Next.js FAQ

Yes. Server Components run only on the server, so they can import the Prisma client and await queries directly. The query code and the client never reach the browser.

In development, Next.js hot-reloads modules on every save. Without a singleton stored on globalThis, each reload creates a new Prisma client with its own connection pool, and you quickly run out of database connections.

Use Server Actions for writes triggered by your own UI, such as forms and buttons. Use Route Handlers when an external client needs an HTTP API, such as a mobile app or a webhook provider. Both need their own authentication and validation.

Prisma ORM 7 moves connection details into a prisma.config.ts file, uses the prisma-client generator with an explicit output path inside your source code, and connects through a JavaScript driver adapter such as the PostgreSQL adapter. The CLI also no longer loads env files automatically. The singleton pattern and query API stay the same.

Use include or select to load related records in the same query, rather than querying inside a loop. Turn on query logging in development to see the SQL each page produces, and add indexes for the fields you filter and sort on.

Conclusion

Prisma and the Next.js App Router fit together naturally. Define your models in schema.prisma, run prisma migrate dev to keep the database in sync, generate the typed client, and expose it through a single server-only module that survives hot reloading. From there, Server Components read data with fully typed queries, Server Actions validate input and write data, and revalidatePath keeps cached pages fresh.

Before going to production, make sure the client is generated during the build, migrations run with prisma migrate deploy, connections are pooled on serverless platforms, and no database code can leak into Client Components. With those pieces in place, you get a type-safe data layer from the database schema all the way to your React components.

Here are some useful references for going deeper on Prisma with Next.js:

  1. Prisma Docs: Prisma Client API reference — every query, filter, and option.
  2. Prisma Docs: Use Prisma Postgres with Next.js — the official Next.js guide for Prisma ORM 8, if you start a new project on the rewrite.
  3. Prisma Docs: Upgrade to Prisma ORM 7 — the config file, generator, and driver adapter changes.
  4. Next.js Docs: Mutating Data — Server Actions, forms, validation, and revalidation in the App Router.
  5. Next.js Docs: connection — opting a render into request time when data does not come from fetch.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading