Using Prisma with Next.js
29. November, 2024 • 6 min read • Develop
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.
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
npx prisma initThat gives you a prisma/ folder containing schema.prisma, and a .env file for the connection string.
Configure the database
In .env:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"Then describe your data in prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
}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 initThis writes a migration file, applies it, and regenerates the client. 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 { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;The reason is 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 global 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 generatein the build. Generated client code is not in your repository, and some platforms restorenode_modulesfrom a cache that predates your schema change. Adding it to apostinstallor build script removes an entire category of confusing production failure. - Run
prisma migrate deployin the pipeline, notmigrate dev. Thedevcommand is interactive and will happily offer to reset your database.deployapplies pending migrations and nothing else. - Watch the connection pool on serverless. Each function instance opens its own connections, 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. Obvious, and still worth writing down.
Habits worth keeping
- Import
prismafromlib/, 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. - 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
The generated client is large, and prisma generate is a step you have to remember in every environment. 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.
That is a small complaint against a schema that types itself. I will take the trade.
‘Till next time!