Prisma Without Prisma Client: Type-Safe Kysely Queries in Next.js
Learn how to keep Prisma for schema work, generate Kysely types with prisma-kysely, pick a Neon or pg dialect, and query type-safely from Next.js server code.
This article is published in English.
Sources: **Using Prisma for Schema and Kysely for Queries in a Next.js App**
Prisma is excellent for schema design, but Prisma Client is a sizeable dependency to ship into every serverless function when all you want is type-safe SQL. A practical split: Prisma manages the schema during development, a generator turns it into TypeScript types, and Kysely runs the queries in production. This guide sets that up in a Next.js App Router project and compares a Neon HTTP dialect with a classic pg pool.
If you want the conventional route with Prisma Client at runtime instead, see setting up Prisma 7 in a Next.js 16 project.
How the responsibilities divide
Each tool in this setup has one clear job:
- Prisma owns the database design: the schema file, syncing or migrating the database, and tools such as Prisma Studio for browsing data.
- Kysely is the runtime query builder. You write SQL-shaped queries in TypeScript, the compiler checks table and column names, and the library itself is small.
- prisma-kysely is the glue: a Prisma generator that emits Kysely type definitions straight from your Prisma schema.
Prisma stays in your developer workflow, while Kysely is the only database layer in deployed code. The trade-off: you lose Prisma Client's relation-aware API, such as nested include and nested writes, and write joins yourself. If you are still choosing a data layer in general, the comparison of raw SQL, Prisma and Drizzle covers the wider decision.
Installing the packages
The runtime needs Kysely plus a dialect and a driver; this installs the Neon-based stack (pnpm here, but npm or yarn work too):
pnpm add kysely kysely-neon @neondatabase/serverless
pnpm add -D prisma prisma-kysely
Note that prisma and prisma-kysely are dev dependencies: they only manage the schema and generate code.
Defining the schema and generating Kysely types
The schema is ordinary Prisma, except that the generator is prisma-kysely (writing into ./types) instead of the usual client generator. It defines User and Post with a one-to-many relation.
// db/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator kysely {
provider = "prisma-kysely"
output = "./types"
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
createdAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
}
prisma generate now produces Kysely types rather than a client, and prisma db push applies the schema to the database:
pnpm prisma generate
pnpm prisma db push
The output is a file such as db/types/kysely.d.ts with a DB interface describing every table. Three caveats:
- The schema lives in
db/schema.prisma, not Prisma's defaultprisma/schema.prisma, so point the CLI at it (for example with--schema) as your Prisma version requires. db pushsyncs the database without recording migration files. For production data,prisma migrate devandprisma migrate deploygive versioned migrations with the rest of the workflow unchanged.- Recent Prisma releases move connection settings into a separate config file; if the
urlline is rejected, check the current docs.
Package scripts for the everyday loop
A few scripts make the workflow obvious to the team:
{
"scripts": {
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
}
}
With these in place, a schema change comes down to two commands:
# To generate the types
pnpm db:generate
# To push to the remote DB
pnpm db:push
The database and the Kysely types then stay aligned, so the compiler flags any query touching a renamed or removed column.
Picking a Kysely dialect
Kysely does not talk to the database itself. A dialect sits between the query builder and the driver and knows how to compile and send queries for one database and connection method. Official dialects cover PostgreSQL, MySQL and SQLite; community ones cover services such as Neon, PlanetScale and Turso. For Postgres, two options cover most cases.
Neon over HTTP for serverless deployments
On Vercel or a similar platform, Neon's HTTP driver fits well: each query is a stateless request, so there is no persistent connection to maintain between short invocations. The setup wraps neon() in NeonDialect and types Kysely with the generated DB:
// db/index.ts
import { Kysely } from 'kysely'
import { NeonDialect } from 'kysely-neon'
import { neon } from '@neondatabase/serverless'
import type { DB } from './types/kysely'
const db = new Kysely<DB>({
dialect: new NeonDialect({
neon: neon(process.env.DATABASE_URL!),
}),
})
export default db
The ! only silences TypeScript; it validates nothing. The bigger limitation is that kysely-neon over HTTP cannot run transactions or session-based queries, so it suits simple CRUD and read-heavy pages rather than multi-statement work. With Neon's WebSocket driver you do not need kysely-neon at all: pass Neon's Pool to Kysely's built-in PostgresDialect. The kysely-neon API has also changed between releases, so compare with its current README.
node-postgres for long-running servers
For a long-lived Node.js server, the official pg driver is the most complete choice, with transactions, pooling and prepared statements. It plugs into Kysely's own PostgresDialect:
// db/index.ts
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
import type { DB } from './types/kysely'
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: process.env.DATABASE_URL,
}),
}),
})
export default db
Choosing between them
The difference is the connection model. kysely-neon is stateless HTTP: light and serverless-friendly, but no transactions. pg keeps pooled TCP connections open, with the full feature set, which suits servers that stay up. Use kysely-neon on serverless platforms such as Vercel or Netlify, and pg or the Neon WebSocket pool once you need transactions. Beware a pg pool inside serverless functions: each instance opens its own connections and can exhaust the database limit without an external pooler.
Querying from a Server Component
Any server-side code can import db. This Server Component lists users, selecting three columns ordered by id:
// app/users/page.tsx
import db from '@/db'
export default async function UsersPage() {
const users = await db
.selectFrom('user')
.select(['id', 'name', 'email'])
.orderBy('id')
.execute()
return (
<div>
<h1>Users</h1>
<ul>
{users.map(u => (
<li key={u.id}>{u.name} ({u.email})</li>
))}
</ul>
</div>
)
}
Since DB comes from the Prisma schema, a misspelled table or column fails type-checking, and users is inferred with exactly the selected fields.
One catch: Prisma names tables after models by default, so this schema creates a table User, and the generated types use that name. selectFrom('user') only type-checks if you add @@map("user") to the model; otherwise query 'User'. Also keep db out of Client Components, as it reads a server-only secret.
The day-to-day workflow
Once everything is wired, the loop looks like this:
- Change models in
db/schema.prisma. - Regenerate types and apply the change with the two scripts.
- Use
dbfrom Server Components, route handlers or Server Actions. - Validate input with Zod or similar before inserting; Kysely types check query shape, not form values.
The resulting layout keeps everything database-related under db/:
📦 my-next-app
├── 📁 app
│ └── 📁 users
│ └── page.tsx
├── 📁 db
│ ├── schema.prisma
│ ├── index.ts
│ └── 📁 types
│ └── kysely.d.ts
├── 📁 node_modules
├── .env
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
Wrapping up
This split keeps Prisma's schema workflow and Studio without shipping Prisma Client, and gives you a small SQL-shaped query layer whose types track the schema.
- Keep
prismaandprisma-kyselyas dev dependencies; only Kysely, a dialect and a driver ship to production. - Use the Neon HTTP dialect for simple serverless workloads, and switch to a pooled
PostgresDialectwhen you need transactions. - Watch table naming: generated types follow the database names, which default to the Prisma model names.
- Move from
db pushto proper migrations before the data in the database starts to matter.