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

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

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

Vé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 tsx

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

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

Ouvrez 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 max ou fermez l'accès à la base dans les invocations serverless de longue durée avec db.destroy().
  • La sortie codegen est vide : confirmez que DATABASE_URL est 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és snake_case mais 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 de selectAll() en production pour ne pas extraire de colonnes inutiles.
  • Utilisez executeTakeFirst() au lieu de execute() 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 pgbouncer ou 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Comment ecrire votre premier SKILL.md — Guide complet pour les agents de codage IA.

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