Using Prisma with Next.js

Coming to Prisma from the Django ORM

My roots are in Django, and the Django ORM spoils you. Models are the schema, migrations are generated from them, and the query API knows what your tables look like. Moving to a Node stack, the thing I missed most was not a framework. It was that.

Prisma is the closest the JavaScript world has come. You write a schema, it generates a typed client from it, and your editor knows the shape of every row before you run anything. Paired with Next.js, where server components and server actions have made database access on the server the normal thing again, it fits well enough that I stopped looking for alternatives.

Here is how I set it up, and the parts I get wrong when I am not paying attention.

Revised in September 2026. The original was written against Prisma 5, and Prisma 7 changed the setup underneath it: a different generator, a config file, a database driver you install yourself, and a generate step that no longer runs on its own. Everything below is what works with 7. If you have the older setup, the section near the end covers moving it across, and it is shorter than you would expect.

Why it works with Next.js in particular

The schema is a single file, and everything derives from it. Change a model, run one command, and the generated client, the types and the migration all follow. There is no second place where the shape of your data is written down, which is the failure mode of every hand-rolled query builder I have used.

The rest of the fit comes from Next.js rather than Prisma:

  • Server components and server actions run on the server, which is the only place a database client belongs.
  • API route handlers are still there when you want a real HTTP endpoint.
  • The generated types flow straight into your components, so a renamed column becomes a type error rather than an undefined at runtime.

The one thing worth saying out loud: none of this makes Prisma safe to import in a client component. More on that below, because it is the mistake everyone makes once.

Setting it up

Install and initialise

npm install prisma --save-dev
npm install @prisma/client @prisma/adapter-pg dotenv
npx prisma init

That gives you a prisma/ folder with schema.prisma, a prisma.config.ts at the project root, and a .env file for the connection string. The config file is new in 7 and it is where the CLI now looks for the database, so the schema no longer knows the connection string at all.

The adapter package is the other newcomer. Prisma 7 talks to Postgres through the same pg driver you would use without Prisma, and @prisma/adapter-pg is the wrapper that hands it over. There is one per database; SQLite gets @prisma/adapter-better-sqlite3, and MongoDB is not supported in 7 at the time of writing, so a Mongo project stays on 6.

Configure the database

In .env:

DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

Then tell the CLI where things are, in prisma.config.ts:

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

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: env('DATABASE_URL'),
  },
});

The dotenv import is not decoration. Prisma 7 stopped loading .env on its own, so without that line env('DATABASE_URL') comes back empty and the error talks about a missing URL rather than a missing file. Next.js loads .env for the application itself; this import only serves the CLI.

Now describe your data in prisma/schema.prisma:

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

datasource db {
  provider = "postgresql"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
}

Two things changed here compared with the versions people copy from older tutorials. The generator is prisma-client, the one without a Rust engine, and output is required: the client is written into your project instead of into node_modules, which is why the import in the next section points at a folder rather than a package. The path is relative to the schema file, so ../generated/prisma lands next to prisma/ in the project root. Add generated/ to .gitignore; it is build output.

And the url line is gone from the datasource block, because the config file owns it now. If you leave it in, the CLI tells you it is deprecated and ignores it, which is polite but not helpful when the two disagree.

The ? on content is the nullable marker, and it propagates all the way into the generated TypeScript as string | null. Small thing, but it is the reason the types are worth having.

Migrate

npx prisma migrate dev --name init
npx prisma generate

Two commands where there used to be one. migrate dev writes a migration file and applies it, but since 7 it no longer regenerates the client afterwards. You run generate yourself, you will forget, and the type error that follows is the reminder.

The migration lands in prisma/migrations/ as plain SQL, which means you can read it before it touches anything you care about. I do read it. Prisma is good at working out what changed, but it cannot know that the column you renamed had data in it.

The client singleton, first

Before writing a single query, create this file. Every example after it depends on it.

// lib/prisma.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

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

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

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

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

The adapter in the constructor is the Prisma 7 change with the most reasoning behind it. Earlier versions carried a query engine written in Rust, downloaded as a binary for your platform at install time, and it did the talking to the database. Seven drops it. The client is TypeScript all the way down, the database connection is a plain pg pool, and you hand that pool to Prisma. No more binary to fetch during npm install, which is one deploy-time failure I do not miss.

The rest of the file is about hot reloading. In development, Next.js re-evaluates modules on every save, and a bare new PrismaClient() at module scope means a fresh client and a fresh connection pool each time. After twenty saves your database starts refusing connections and the error message points at nothing useful. Stashing the instance on globalThis in development only survives the reload; in production the module is evaluated once and the guard does nothing.

Plenty of tutorials instantiate the client inline in each example because it reads more clearly. It also reproduces the bug.

Querying

Server actions are where most of my database code lives now.

// app/actions.ts
'use server';

import { prisma } from '@/lib/prisma';

export async function getPublishedPosts() {
  return prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  });
}

Then call it from a server component and render the result during the server render:

// app/posts/page.tsx
import { getPublishedPosts } from '../actions';

export default async function PostsPage() {
  const posts = await getPublishedPosts();

  return (
    <main>
      <h1>Published Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

No fetch, no API route, no serialisation boundary in the middle. The query runs on the server, the HTML comes back with the data in it, and the connection string never leaves the machine.

Route handlers are still the right answer when something outside your application needs the data:

// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET() {
  const posts = await prisma.post.findMany();

  return NextResponse.json(posts);
}

If you are on the Pages Router, the same client works inside getServerSideProps and pages/api/* handlers. Nothing about Prisma changes; only where you call it from.

Deploying

Prisma runs fine on Divio, Vercel and Railway. I have written before about hosting on Divio, and the same rules apply wherever you land:

  • Run prisma generate in the build. The generated client lives in a folder you have ignored, so the build has to create it. Put it in the build script or a postinstall. Prisma 7 makes this less forgiving than before, because nothing else runs it for you any more.
  • Run prisma migrate deploy in the pipeline, not migrate dev. The dev command is interactive and will happily offer to reset your database. deploy applies pending migrations and nothing else.
  • Watch the connection pool on serverless. Each function instance opens its own pool, and a managed Postgres box has a low ceiling. A pooler such as PgBouncer or your provider’s own is not optional once you have real traffic.
  • Keep the connection string in environment variables. The CLI reads it through prisma.config.ts, the application reads it in the singleton, and both need it set in every environment. Obvious, and still worth writing down, because the build and the runtime are often configured in two different places.

Moving an older project to 7

If you have the 2024 version of this setup, the change is mechanical and took me less time than reading the upgrade guide did. In order:

  1. Upgrade the packages and add the two new ones: npm install prisma@7 @prisma/client@7 @prisma/adapter-pg dotenv.
  2. In the schema, rename the generator to prisma-client, give it an output, and delete url from the datasource. Create prisma.config.ts with the URL in it.
  3. Replace import { PrismaClient } from '@prisma/client' with the generated path, and pass the adapter to the constructor.
  4. Run prisma generate, then the type checker. The renamed import surfaces every file that constructed its own client, which is the audit you should have done anyway.
  5. If you used prisma.$use() middleware, it is gone in 7. Client extensions do the same job with a different shape.
  6. Delete the generate step you never had, then add it to the build, because now you need it.

The generated client is ESM. In a Next.js project that costs nothing, since Next handles modules itself. In a plain Node script you either set "type": "module" in package.json or tell the generator you want CommonJS, and the guide covers both.

Habits worth keeping

  • Import prisma from lib/, never construct a client anywhere else.
  • Keep it out of client components. If a file has 'use client' at the top, Prisma has no business in it, and the error you get if you try is not a helpful one.
  • Run prisma generate after every migration, and put it in the build so production does not depend on your memory.
  • Use TypeScript. Prisma with plain JavaScript works, but you have given up the reason to use Prisma.
  • Read the generated migration before applying it to anything with data in it.

What still annoys me

prisma generate is a step you have to remember in every environment, and Prisma 7 made it a step you have to remember twice, once after each migration and once in the build. Coming from Django, where migrations and the ORM live inside the framework, having a separate CLI that has to run at the right moment feels like something that should not be my problem.

The move to a client written in TypeScript and a driver I already understood is a real improvement, and the config file is where the connection string always belonged. That is a small complaint against a schema that types itself. I will take the trade.

‘Till next time!