Kysely : Constructeur de Requêtes SQL Type-Safe avec Next.js et PostgreSQL 2026

Kysely est un constructeur de requêtes SQL conçu d'abord pour TypeScript, qui vous offre la sécurité d'un ORM avec la transparence du SQL brut. Contrairement à Prisma ou Drizzle, Kysely ne cache jamais ce qui est réellement exécuté — chaque requête se lit comme du SQL et chaque colonne est entièrement typée. Dans ce tutoriel, vous allez construire une application Next.js 15 de qualité production utilisant Kysely avec PostgreSQL, incluant migrations, requêtes avancées et intégration avec les Server Actions.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20 ou plus récent installé
- Une instance PostgreSQL 15 ou plus récente (locale ou cloud — Neon, Supabase ou Railway fonctionnent tous)
- Une connaissance de base de TypeScript et SQL
- Un éditeur de code (VS Code recommandé)
- Une connaissance des fondamentaux du Next.js App Router
Ce Que Vous Allez Construire
Une API complète de gestion de tâches pour une petite équipe, comprenant :
- Définitions de schéma type-safe générées automatiquement depuis PostgreSQL
- Opérations CRUD via les Server Actions de Next.js 15
- Jointures complexes, agrégations et transactions
- Migrations de schéma gérées par le migrator de Kysely
- IntelliSense complet pour chaque colonne, dans chaque requête
À la fin, vous disposerez d'une base solide qui passe à l'échelle d'une seule table à des dizaines, le tout avec une sécurité de types de bout en bout.
Étape 1 : Configuration du Projet
Créez un nouveau projet Next.js 15 avec TypeScript et Tailwind CSS préconfigurés.
npx create-next-app@latest kysely-tasks --typescript --tailwind --app --src-dir --eslint
cd kysely-tasksVérifiez que le projet démarre correctement avant de continuer.
npm run devÉtape 2 : Installer Kysely et les Dépendances PostgreSQL
Installez Kysely avec le pilote PostgreSQL officiel et un générateur de code qui inspecte votre schéma de base de données.
npm install kysely pg
npm install -D @types/pg kysely-codegen tsxLe paquet kysely-codegen lit votre schéma PostgreSQL en direct et émet automatiquement les types TypeScript — c'est la magie qui alimente l'IntelliSense complet sans écrire de types à la main.
Étape 3 : Configurer les Variables d'Environnement
Créez un fichier .env.local à la racine du projet.
DATABASE_URL=postgres://user:password@localhost:5432/kysely_tasksSi vous n'avez pas d'instance PostgreSQL locale, démarrez-en une rapidement avec Docker.
docker run --name kysely-pg -e POSTGRES_PASSWORD=password -e POSTGRES_DB=kysely_tasks -p 5432:5432 -d postgres:16Étape 4 : Créer Votre Première Migration
Kysely est livré avec un migrator intégré. Créez un dossier migrations et un script pour exécuter les migrations en attente.
Créez 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();
}Notez comment chaque type de colonne, chaque valeur par défaut et chaque contrainte est exprimée en TypeScript pur avec autocomplétion complète.
Étape 5 : Brancher le Migrator
Créez src/db/migrate.ts — c'est le script que vous exécutez pour appliquer les 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}" appliquée`);
} else if (r.status === "Error") {
console.error(`Échec de "${r.migrationName}"`);
}
});
if (error) {
console.error("Échec de la migration :", error);
process.exit(1);
}
await db.destroy();
}
migrate();Ajoutez un script à votre 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"
}
}Appliquez vos migrations.
npm run db:migrateÉtape 6 : Générer les Définitions de Types
Exécutez maintenant le générateur de code. Il inspecte votre base de données en direct et émet un fichier types.ts représentant chaque table.
DATABASE_URL=$DATABASE_URL npm run db:codegenOuvrez src/db/types.ts — vous verrez une interface DB entièrement typée que Kysely utilise pour assurer la sécurité de chaque requête.
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>;
}Étape 7 : Créer le Client de Base de Données
Créez src/db/client.ts pour instancier une seule instance Kysely pour l'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;
}Le CamelCasePlugin convertit automatiquement les colonnes snake_case de PostgreSQL en camelCase dans TypeScript. Le cache global empêche le hot reload de Next.js de créer des pools de connexions dupliqués en développement.
Étape 8 : Construire les Server Actions CRUD
Créez src/app/actions/tasks.ts — un module de Server Actions alimenté par 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("/");
}Remarquez l'IntelliSense — essayez de renommer tasks.title en tasks.titlex et observez TypeScript rejeter immédiatement la compilation. Il n'y a aucune surcharge à l'exécution car tout se compile en SQL pur.
Étape 9 : Construire l'Interface Utilisateur
Créez une page minimale de liste de tâches dans 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">Tâches de l'Équipe</h1>
<form action={createTask} className="flex gap-2 mb-8">
<input
type="text"
name="title"
placeholder="Que faut-il faire ?"
className="flex-1 border rounded px-3 py-2"
required
/>
<button type="submit" className="bg-black text-white px-4 py-2 rounded">
Ajouter
</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">
Assigné à {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" ? "Rouvrir" : "Terminer"}
</button>
</form>
</li>
))}
</ul>
</main>
);
}Étape 10 : Maîtriser les Requêtes Avancées
C'est dans les requêtes SQL avancées que Kysely brille véritablement. Voici des modèles que vous utiliserez constamment.
Agrégations et regroupements
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: "Bienvenue à bord",
assigneeId: user.id,
})
.execute();
});Si quelque chose dans le callback lève une exception, Kysely annule automatiquement toute la transaction.
Étape 11 : Échappatoires SQL Brut Type-Safe
Lorsque vous avez besoin de fonctionnalités PostgreSQL que Kysely n'abstrait pas encore, repliez-vous sur du SQL brut typé.
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();Le tag de template sql préserve l'inférence de types de bout en bout tout en vous permettant d'utiliser n'importe quelle extension PostgreSQL dont vous avez besoin.
Étape 12 : Tester Votre Implémentation
Démarrez une base de données temporaire pour les tests avec pg-mem ou un conteneur Docker, puis exécutez des tests d'intégration avec Vitest.
import { describe, it, expect, beforeEach } from "vitest";
import { db } from "@/db/client";
describe("tasks", () => {
beforeEach(async () => {
await db.deleteFrom("tasks").execute();
});
it("crée et lit une tâche", async () => {
await db
.insertInto("tasks")
.values({ title: "Livrer" })
.execute();
const found = await db
.selectFrom("tasks")
.selectAll()
.where("title", "=", "Livrer")
.executeTakeFirst();
expect(found?.title).toBe("Livrer");
});
});Dépannage
Problèmes courants et comment les résoudre.
- Pool de connexions épuisé : réduisez la valeur
maxou fermez l'accès à la base dans les invocations serverless de longue durée avecdb.destroy(). - La sortie codegen est vide : confirmez que
DATABASE_URLest joignable depuis votre shell et que la base a au moins une table définie par l'utilisateur. - Incohérence CamelCase : si vous oubliez le
CamelCasePlugin, les requêtes retournent des cléssnake_casemais les types générés attendent du camelCase. Associez toujours le plugin avec le drapeau codegen--camel-case. - Migrations non détectées : les noms de fichiers doivent suivre l'ordre lexicographique. Utilisez un préfixe de date tel que
2026_01_01_afin qu'ils soient triés correctement.
Conseils de Performance
- Ajoutez toujours des appels
select()explicites au lieu deselectAll()en production pour ne pas extraire de colonnes inutiles. - Utilisez
executeTakeFirst()au lieu deexecute()lorsque vous avez besoin d'une seule ligne — cela évite de construire un tableau. - Enveloppez les écritures multi-étapes dans des transactions pour éviter les échecs partiels.
- Combinez Kysely avec
pgbouncerou le pooling de connexions de Neon pour les déploiements serverless.
Étapes Suivantes
Maintenant que vous avez une couche de base de données type-safe fonctionnelle, envisagez de l'étendre avec :
- Réplicas en lecture utilisant plusieurs instances Kysely
- Politiques de sécurité au niveau des lignes sur PostgreSQL avec
withSchema()de Kysely - Tâches d'arrière-plan qui invoquent la même couche de requêtes depuis un worker
- Coupler Kysely avec notre tutoriel Drizzle ORM pour comparer les approches
- Déployer en production avec notre guide Coolify
Conclusion
Kysely trouve un équilibre unique — il vous offre toute la puissance et la transparence du SQL sans jamais compromettre la sécurité TypeScript. Vous avez écrit de vraies requêtes, utilisé de vraies jointures et livré de vraies migrations, le tout sans qu'un ORM ne cache le comportement derrière une couche de magie. Pour les équipes qui pensent déjà en SQL, Kysely est souvent le chemin le plus propre vers une application Next.js maintenable et type-safe.
Chaque fois que vous vous trouvez à lutter contre les abstractions d'un ORM ou à vous demander quelle requête a réellement été exécutée, rappelez-vous que Kysely vous permet de lire et d'écrire exactement le SQL que vous aviez prévu — avec le compilateur qui veille sur vous.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Recherche plein texte PostgreSQL avec Next.js — Construire une recherche puissante sans Elasticsearch (2026)
Apprenez à construire une recherche plein texte rapide et tolérante aux fautes de frappe en utilisant les capacités intégrées de PostgreSQL avec Next.js App Router. Pas besoin d'Elasticsearch ou d'Algolia — juste votre base de données Postgres existante.

Construire une application full-stack avec Prisma ORM et Next.js 15 App Router
Apprenez à construire une application full-stack avec Prisma ORM, Next.js 15 App Router et PostgreSQL. Ce tutoriel couvre la modélisation du schéma, les migrations, les Server Actions, les opérations CRUD, les relations et le déploiement en production.