Kysely Type-Safe SQL Query Builder with Next.js and PostgreSQL 2026

Kysely is a TypeScript-first SQL query builder that gives you the safety of an ORM with the transparency of raw SQL. Unlike Prisma or Drizzle, Kysely never hides what's actually being executed — every query reads like SQL, and every column is fully typed. In this tutorial, you'll build a production-grade Next.js 15 application using Kysely with PostgreSQL, including migrations, advanced queries, and Server Actions integration.
Prerequisites
Before starting, ensure you have:
- Node.js 20 or later installed
- A PostgreSQL 15 or later instance (local or cloud — Neon, Supabase, or Railway all work)
- Basic familiarity with TypeScript and SQL
- A code editor (VS Code recommended)
- Knowledge of Next.js App Router fundamentals
What You'll Build
A complete task management API for a small team, featuring:
- Type-safe schema definitions auto-generated from PostgreSQL
- CRUD operations through Next.js 15 Server Actions
- Complex joins, aggregations, and transactions
- Schema migrations managed by Kysely's migrator
- Full IntelliSense for every column, in every query
By the end, you will have a robust foundation that scales from a single table to dozens, all with end-to-end type safety.
Step 1: Project Setup
Create a new Next.js 15 project with TypeScript and Tailwind CSS preconfigured.
npx create-next-app@latest kysely-tasks --typescript --tailwind --app --src-dir --eslint
cd kysely-tasksVerify the project runs cleanly before continuing.
npm run devStep 2: Install Kysely and PostgreSQL Dependencies
Install Kysely along with the official PostgreSQL driver and a code generator that introspects your database schema.
npm install kysely pg
npm install -D @types/pg kysely-codegen tsxThe kysely-codegen package reads your live PostgreSQL schema and emits TypeScript types automatically — this is the magic that powers full IntelliSense without writing types by hand.
Step 3: Configure Environment Variables
Create a .env.local file at the project root.
DATABASE_URL=postgres://user:password@localhost:5432/kysely_tasksIf you do not have a local PostgreSQL instance, spin one up quickly with Docker.
docker run --name kysely-pg -e POSTGRES_PASSWORD=password -e POSTGRES_DB=kysely_tasks -p 5432:5432 -d postgres:16Step 4: Create Your First Migration
Kysely ships with a built-in migrator. Create a migrations folder and a script to run pending migrations.
Create src/db/migrations/2026_01_01_create_tasks.ts.
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("users")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`)
)
.addColumn("email", "varchar(255)", (col) => col.notNull().unique())
.addColumn("name", "varchar(255)", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`)
)
.execute();
await db.schema
.createTable("tasks")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`)
)
.addColumn("title", "varchar(500)", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("status", "varchar(20)", (col) =>
col.notNull().defaultTo("todo")
)
.addColumn("assignee_id", "uuid", (col) =>
col.references("users.id").onDelete("set null")
)
.addColumn("due_date", "date")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`)
)
.execute();
await db.schema
.createIndex("tasks_assignee_idx")
.on("tasks")
.column("assignee_id")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("tasks").execute();
await db.schema.dropTable("users").execute();
}Notice how every column type, every default, and every constraint is expressed in pure TypeScript with full autocomplete.
Step 5: Wire Up the Migrator
Create src/db/migrate.ts — this is the script you run to apply migrations.
import { promises as fs } from "node:fs";
import path from "node:path";
import { Kysely, Migrator, FileMigrationProvider, PostgresDialect } from "kysely";
import { Pool } from "pg";
async function migrate() {
const db = new Kysely<any>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(process.cwd(), "src/db/migrations"),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach((r) => {
if (r.status === "Success") {
console.log(`Migration "${r.migrationName}" applied`);
} else if (r.status === "Error") {
console.error(`Failed to apply "${r.migrationName}"`);
}
});
if (error) {
console.error("Migration failed:", error);
process.exit(1);
}
await db.destroy();
}
migrate();Add a script to your package.json.
{
"scripts": {
"db:migrate": "tsx --env-file=.env.local src/db/migrate.ts",
"db:codegen": "kysely-codegen --out-file src/db/types.ts --camel-case"
}
}Apply your migrations.
npm run db:migrateStep 6: Generate Type Definitions
Now run the code generator. It introspects your live database and emits a types.ts file representing every table.
DATABASE_URL=$DATABASE_URL npm run db:codegenOpen src/db/types.ts — you'll see a fully typed DB interface that Kysely uses to enforce safety on every query.
export interface DB {
users: Users;
tasks: Tasks;
}
export interface Users {
id: Generated<string>;
email: string;
name: string;
createdAt: Generated<Date>;
}
export interface Tasks {
id: Generated<string>;
title: string;
description: string | null;
status: Generated<string>;
assigneeId: string | null;
dueDate: Date | null;
createdAt: Generated<Date>;
}Step 7: Create the Database Client
Create src/db/client.ts to instantiate a single Kysely instance for the application.
import { Kysely, PostgresDialect, CamelCasePlugin } from "kysely";
import { Pool } from "pg";
import type { DB } from "./types";
const globalForDb = globalThis as unknown as {
db: Kysely<DB> | undefined;
};
export const db =
globalForDb.db ??
new Kysely<DB>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
}),
}),
plugins: [new CamelCasePlugin()],
});
if (process.env.NODE_ENV !== "production") {
globalForDb.db = db;
}The CamelCasePlugin automatically converts snake_case columns in PostgreSQL to camelCase in TypeScript. The global cache prevents Next.js hot reload from creating duplicate connection pools in development.
Step 8: Build CRUD Server Actions
Create src/app/actions/tasks.ts — a Server Actions module powered by Kysely.
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/db/client";
export async function createTask(formData: FormData) {
const title = formData.get("title") as string;
const assigneeId = (formData.get("assigneeId") as string) || null;
const task = await db
.insertInto("tasks")
.values({
title,
assigneeId,
})
.returningAll()
.executeTakeFirstOrThrow();
revalidatePath("/");
return task;
}
export async function listTasks() {
return db
.selectFrom("tasks")
.leftJoin("users", "users.id", "tasks.assigneeId")
.select([
"tasks.id",
"tasks.title",
"tasks.description",
"tasks.status",
"tasks.dueDate",
"users.name as assigneeName",
])
.orderBy("tasks.createdAt", "desc")
.execute();
}
export async function updateTaskStatus(id: string, status: string) {
await db
.updateTable("tasks")
.set({ status })
.where("id", "=", id)
.execute();
revalidatePath("/");
}
export async function deleteTask(id: string) {
await db.deleteFrom("tasks").where("id", "=", id).execute();
revalidatePath("/");
}Notice the IntelliSense — try renaming tasks.title to tasks.titlex and watch TypeScript reject the build instantly. There is no runtime overhead because everything compiles to plain SQL.
Step 9: Build the UI
Create a minimal task list page at src/app/page.tsx.
import { listTasks, createTask, updateTaskStatus } from "./actions/tasks";
export default async function HomePage() {
const tasks = await listTasks();
return (
<main className="max-w-3xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Team Tasks</h1>
<form action={createTask} className="flex gap-2 mb-8">
<input
type="text"
name="title"
placeholder="What needs doing?"
className="flex-1 border rounded px-3 py-2"
required
/>
<button type="submit" className="bg-black text-white px-4 py-2 rounded">
Add
</button>
</form>
<ul className="space-y-2">
{tasks.map((task) => (
<li
key={task.id}
className="border rounded p-4 flex items-center justify-between"
>
<div>
<div className="font-medium">{task.title}</div>
{task.assigneeName && (
<div className="text-sm text-gray-600">
Assigned to {task.assigneeName}
</div>
)}
</div>
<form
action={async () => {
"use server";
await updateTaskStatus(
task.id,
task.status === "done" ? "todo" : "done"
);
}}
>
<button className="text-sm text-blue-600">
{task.status === "done" ? "Reopen" : "Complete"}
</button>
</form>
</li>
))}
</ul>
</main>
);
}Step 10: Master Advanced Queries
Kysely truly shines for advanced SQL. Here are patterns you will use constantly.
Aggregations and grouping
import { sql } from "kysely";
const stats = await db
.selectFrom("tasks")
.select([
"status",
db.fn.count<number>("id").as("total"),
sql<number>`COUNT(*) FILTER (WHERE due_date < now())`.as("overdue"),
])
.groupBy("status")
.execute();Common Table Expressions
const result = await db
.with("recent_tasks", (cte) =>
cte
.selectFrom("tasks")
.selectAll()
.where("createdAt", ">", new Date(Date.now() - 7 * 86400000))
)
.selectFrom("recent_tasks")
.selectAll()
.where("status", "=", "todo")
.execute();Transactions
await db.transaction().execute(async (trx) => {
const user = await trx
.insertInto("users")
.values({ email: "alice@example.com", name: "Alice" })
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("tasks")
.values({
title: "Welcome onboard",
assigneeId: user.id,
})
.execute();
});If anything inside the callback throws, Kysely automatically rolls back the entire transaction.
Step 11: Type-Safe Raw SQL Escape Hatches
When you need PostgreSQL features Kysely does not yet abstract, fall back to typed raw SQL.
import { sql } from "kysely";
const fuzzyMatches = await db
.selectFrom("tasks")
.select(["id", "title"])
.where(sql<boolean>`title % ${"deploy"}`)
.orderBy(sql`similarity(title, ${"deploy"})`, "desc")
.limit(10)
.execute();The sql template tag preserves type inference end-to-end while letting you reach for any PostgreSQL extension you need.
Step 12: Testing Your Implementation
Spin up a temporary database for tests using pg-mem or a Docker container, then run integration tests with Vitest.
import { describe, it, expect, beforeEach } from "vitest";
import { db } from "@/db/client";
describe("tasks", () => {
beforeEach(async () => {
await db.deleteFrom("tasks").execute();
});
it("creates and reads a task", async () => {
await db
.insertInto("tasks")
.values({ title: "Ship it" })
.execute();
const found = await db
.selectFrom("tasks")
.selectAll()
.where("title", "=", "Ship it")
.executeTakeFirst();
expect(found?.title).toBe("Ship it");
});
});Troubleshooting
Common issues and how to resolve them.
- Connection pool exhausted: lower the
maxvalue or close database access in long-running serverless invocations usingdb.destroy(). - Codegen output is empty: confirm
DATABASE_URLis reachable from your shell and that the database has at least one user-defined table. - CamelCase mismatch: if you forget the
CamelCasePlugin, queries returnsnake_casekeys but the generated types expect camelCase. Always pair the plugin with the--camel-casecodegen flag. - Migrations not detected: filenames must follow lexicographic order. Use a date prefix such as
2026_01_01_so they sort correctly.
Performance Tips
- Always add explicit
select()calls instead ofselectAll()in production code so you do not pull unnecessary columns. - Use
executeTakeFirst()instead ofexecute()when you need a single row — it avoids constructing an array. - Wrap multi-step writes inside transactions to prevent partial failures.
- Combine Kysely with
pgbounceror Neon's connection pooling for serverless deployments.
Next Steps
Now that you have a working type-safe database layer, consider extending it with:
- Read replicas using multiple Kysely instances
- Row-level security policies on PostgreSQL with Kysely's
withSchema() - Background jobs that invoke the same query layer from a worker
- Pairing Kysely with our Drizzle ORM tutorial to compare approaches
- Deploying to production with our Coolify deployment guide
Conclusion
Kysely strikes a unique balance — it gives you the full power and transparency of SQL while never compromising on TypeScript safety. You wrote real queries, used real joins, and shipped real migrations, all without an ORM hiding behavior behind a layer of magic. For teams that already think in SQL, Kysely is often the cleanest path to a maintainable, type-safe Next.js application.
Whenever you find yourself fighting an ORM's abstractions or wondering what query was actually executed, remember that Kysely lets you read and write exactly the SQL you intended — with the compiler watching your back.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

PostgreSQL Full-Text Search with Next.js — Build Powerful Search Without Elasticsearch (2026)
Learn how to build fast, typo-tolerant full-text search using PostgreSQL's built-in capabilities with Next.js App Router. No Elasticsearch or Algolia needed — just your existing Postgres database.

Build a Full-Stack App with Prisma ORM and Next.js 15 App Router
Learn how to build a full-stack application with Prisma ORM, Next.js 15 App Router, and PostgreSQL. This tutorial covers schema modeling, migrations, Server Actions, CRUD operations, relations, and production deployment.