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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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-tasks

Verify the project runs cleanly before continuing.

npm run dev

Step 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 tsx

The 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_tasks

If 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:16

Step 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:migrate

Step 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:codegen

Open 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 max value or close database access in long-running serverless invocations using db.destroy().
  • Codegen output is empty: confirm DATABASE_URL is reachable from your shell and that the database has at least one user-defined table.
  • CamelCase mismatch: if you forget the CamelCasePlugin, queries return snake_case keys but the generated types expect camelCase. Always pair the plugin with the --camel-case codegen 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 of selectAll() in production code so you do not pull unnecessary columns.
  • Use executeTakeFirst() instead of execute() when you need a single row — it avoids constructing an array.
  • Wrap multi-step writes inside transactions to prevent partial failures.
  • Combine Kysely with pgbouncer or 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.


Want to read more tutorials? Check out our latest tutorial on Embedding Wikipedia Articles for Search.

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