Dall'installazione al primo dato in produzione: guida pratica a Prisma ORM in un progetto Next.js App Router, con PostgreSQL, pattern singleton e query ottimizzate.

Scrivere SQL a mano dentro un'applicazione Next.js si può fare. Lo si è fatto per anni. Ma ogni query scritta come stringa è un punto in cui il compilatore TypeScript smette di aiutarti: un campo rinominato nel database non genera nessun errore in fase di build, lo scopri in produzione. Prisma nasce esattamente per chiudere questo buco. È un ORM che genera un client tipizzato a partire dallo schema del database, così ogni query sbagliata diventa un errore di compilazione, non un ticket di supporto.
In questo articolo costruiamo la pipeline completa: installazione in un progetto Next.js con App Router, definizione dello schema, connessione a PostgreSQL, query di base e i due pattern che fanno la differenza tra un progetto demo e uno che regge il carico reale.
Partiamo da un progetto Next.js esistente (o appena creato con create-next-app). Prisma si divide in due pacchetti: la CLI, che serve solo in sviluppo, e il client, che finisce nel bundle dell'applicazione.
npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider postgresql
Il comando init crea due cose: la cartella prisma/ con dentro il file schema.prisma, e un file .env con la variabile DATABASE_URL. Quest'ultima è la stringa di connessione al database, nel formato standard PostgreSQL:
DATABASE_URL="postgresql://utente:password@localhost:5432/nome_db?schema=public"
Un dettaglio che vale la pena fissare subito: il file .env non va mai committato. Aggiungetelo al .gitignore se non c'è già, e in produzione la variabile va impostata direttamente nell'ambiente di hosting (Vercel, un container, quello che usate).
Tutto in Prisma parte da qui. Lo schema.prisma è un file dichiarativo che descrive tre cose: dove sta il database, come generare il client, e che forma hanno i dati. Le prime due sono i blocchi datasource e generator:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
Il datasource dice a Prisma con quale database parlare e dove trovarlo. Il generator specifica cosa produrre a partire dallo schema: nel caso standard, il Prisma Client in JavaScript/TypeScript. Esistono generator alternativi (per documentazione, per validatori Zod), ma per iniziare basta questo. Il riferimento completo a tutti i blocchi e gli attributi disponibili è nella Prisma Schema API reference.
I modelli sono la parte interessante. Ogni blocco model corrisponde a una tabella, ogni campo a una colonna. Prendiamo un caso concreto e ricorrente: un blog con utenti, articoli e categorie.
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
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
categories Category[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
name String @unique
posts Post[]
}
Qualche punto da notare. Il punto interrogativo (String?) rende un campo opzionale, cioè NULL a livello di database. Gli attributi con la chiocciola (@id, @unique, @default) definiscono vincoli e comportamenti: @updatedAt, per esempio, aggiorna automaticamente il timestamp a ogni modifica del record, senza trigger SQL da scrivere.
Le relazioni meritano due parole in più. Quella tra User e Post è una uno-a-molti classica: il campo author sul modello Post dichiara la relazione, mentre authorId è la foreign key vera e propria che finisce nella tabella. Tra Post e Category c'è invece una molti-a-molti implicita: basta dichiarare gli array su entrambi i lati e Prisma crea da solo la tabella di join. Niente modello intermedio da gestire, a meno che non serva aggiungere metadati alla relazione (in quel caso si passa a una relazione esplicita).
Una volta definito lo schema, due comandi lo portano in vita:
npx prisma migrate dev --name init npx prisma generate
Il primo crea la migrazione SQL e la applica al database. Il secondo genera il client tipizzato in node_modules/@prisma/client. In sviluppo, migrate dev esegue anche la generazione in automatico, ma è utile conoscere i due passaggi come operazioni distinte: in CI/CD li lancerete separatamente (migrate deploy in produzione, mai migrate dev).
Qui arriva il primo problema reale, quello che i tutorial saltano e che in sviluppo si manifesta con un errore piuttosto esplicito: "There are already 10 instances of Prisma Client actively running".
La causa è il hot reload di Next.js. Ogni volta che salvate un file, Next.js ricarica i moduli, e se il client Prisma viene istanziato con un semplice new PrismaClient() in cima a un file, ogni reload crea una nuova istanza con il suo pool di connessioni. Il database locale si satura in pochi minuti di lavoro.
La soluzione è il pattern singleton raccomandato dalla stessa documentazione ufficiale di Prisma: agganciare l'istanza all'oggetto globalThis, che sopravvive ai reload dei moduli.
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
In produzione il problema non esiste (non c'è hot reload), per questo l'assegnazione a globalThis avviene solo fuori da production. Da questo momento, ogni file dell'applicazione importa il client da qui: import { prisma } from '@/lib/prisma'. Sempre. Un new PrismaClient() sparso in un route handler è un bug in attesa di manifestarsi.
Con l'App Router, i Server Components sono il posto naturale per interrogare il database: il codice gira solo sul server, il client Prisma non finisce mai nel bundle del browser, e non serve nessuna API intermedia per leggere i dati. Una pagina che lista gli articoli pubblicati è tutta qui:
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Il componente è async, la query si fa con un await, e posts è già tipizzato: l'editor sa che ogni elemento ha title, published, createdAt. Nessun any, nessun cast. La sintassi completa di filtri e ordinamento è documentata a parte, con tutti gli operatori disponibili su where e orderBy.
Il client espone un metodo per ogni operazione, con una sintassi coerente su tutti i modelli. Le cinque che coprono il 90% dei casi:
// Lettura multipla, con filtri e ordinamento
const published = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
})
// Lettura singola, per chiave unica
const user = await prisma.user.findUnique({
where: { email: 'mario@example.com' },
})
// Creazione
const newPost = await prisma.post.create({
data: {
title: 'Primo articolo',
author: { connect: { id: userId } },
},
})
// Aggiornamento
await prisma.post.update({
where: { id: postId },
data: { published: true },
})
// Cancellazione
await prisma.post.delete({
where: { id: postId },
})
Due note pratiche. findUnique funziona solo su campi marcati @id o @unique nello schema: per filtrare su altri campi si usa findFirst. E nel create, la sintassi connect collega il nuovo record a uno esistente tramite la relazione, senza dover manipolare la foreign key a mano.
Le mutazioni (create, update, delete) nei progetti App Router vivono tipicamente nelle Server Actions o nei route handler, non nei Server Components, che sono pensati per la lettura. La logica però non cambia: stesso client, stessi metodi.
Di default, findMany restituisce i campi scalari del modello e nient'altro. Le relazioni non vengono caricate. Per portarsi dietro l'autore di ogni post si usa include, come descritto nella guida alle relation queries:
const posts = await prisma.post.findMany({
include: { author: true },
})
// posts[0].author.name è disponibile e tipizzato
include aggiunge relazioni al risultato completo. select fa l'opposto: parte da zero e specifica esattamente cosa restituire, campo per campo.
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
author: {
select: { name: true },
},
},
})
La differenza non è cosmetica. Con select, la query SQL generata chiede al database solo le colonne indicate: su un modello con un campo content che pesa decine di kilobyte per record, una lista di 50 articoli passa dal trasferire megabyte inutili al trasferire poche centinaia di byte. In una pagina di listing, dove del contenuto non serve nulla, select è quasi sempre la scelta giusta. include resta comodo quando serve davvero il record intero più le sue relazioni, tipicamente nella pagina di dettaglio.
I due non si combinano allo stesso livello (o l'uno o l'altro), e il tipo di ritorno riflette la selezione: se non avete chiesto content, TypeScript vi impedirà di usarlo. Il che è esattamente il comportamento che si vuole.
Il setup visto fin qui regge senza modifiche il passaggio in produzione, con un'accortezza: in ambienti serverless (Vercel su tutti) ogni istanza della funzione apre le proprie connessioni, e PostgreSQL ha un limite. Sotto carico, conviene mettere davanti al database un connection pooler come PgBouncer, o usare soluzioni gestite che lo includono già (Neon, Supabase). Prisma da parte sua offre anche Prisma Accelerate per il pooling e la cache a livello globale.
Il resto è già a posto: schema versionato con le migrazioni, client tipizzato, un solo punto di istanziazione. Da qui in avanti le direzioni interessanti sono le transazioni con $transaction, i filtri avanzati su relazioni annidate e la validazione dei dati in ingresso prima che arrivino al client. Ma il fondamento è questo, ed è solido.