Introductie: Waarom Drizzle ORM de Standaard Wordt voor Next.js
Welkom bij het vijfde deel in onze serie over Next.js App Router. We hebben inmiddels een stevige basis gelegd: van data ophalen met Server Components en streaming, via Server Actions en formulieren, naar middleware voor authenticatie en beveiliging, en recent Auth.js v5 met OAuth en RBAC. Nu is het tijd voor misschien wel het meest fundamentele onderdeel van elke serieuze applicatie — de database.
Eerlijk gezegd, als je de afgelopen jaren met Next.js hebt gewerkt, was Prisma waarschijnlijk je go-to ORM. En daar is absoluut niks mis mee. Prisma heeft fantastisch werk geleverd om database-interactie toegankelijker te maken voor JavaScript-developers. Maar er is een nieuwe speler die razendsnel terrein wint: Drizzle ORM.
Waarom maken zoveel developers de overstap? Het komt eigenlijk neer op drie kernprincipes:
- SQL-first filosofie — Drizzle's query API voelt als SQL. Als je SQL kent, ken je Drizzle. Geen nieuw query-taal leren, geen magische abstracties die je van de database wegduwen.
- Lichtgewicht en snel — Geen zware engine, geen binaries die geïnstalleerd moeten worden. Drizzle draait volledig in JavaScript/TypeScript en is daardoor razendsnel in serverless omgevingen.
- Type-safety zonder codegeneratie — Waar Prisma een aparte generate-stap nodig heeft, leidt Drizzle types rechtstreeks af uit je schema-definities. Eén bron van waarheid, nul overhead.
In dit artikel bouwen we samen een complete database-integratie op, van de eerste npm install tot een productie-ready deployment op Vercel met Neon PostgreSQL. We gebruiken daarbij de nieuwste features van Drizzle, inclusief de RQBv2 relatie-API uit de v1 beta.
Laten we beginnen.
Drizzle ORM Installeren en Configureren
Packages installeren
We beginnen met het installeren van de benodigde packages. Drizzle heeft een modulaire opzet: de core ORM, een kit voor migraties, en een database-driver naar keuze.
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
We gebruiken hier de Neon serverless driver omdat die perfect werkt in zowel lokale ontwikkeling als in productie op Vercel. Werk je liever met een standaard PostgreSQL-driver? Dan kun je ook postgres (postgres.js) of pg installeren — Drizzle ondersteunt ze allemaal.
Projectstructuur opzetten
Een duidelijke projectstructuur is cruciaal wanneer je database-logica toevoegt aan je app. Dit is de structuur die we in dit artikel aanhouden:
src/
├── app/
│ ├── (blog)/
│ │ ├── posts/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ └── api/
├── db/
│ ├── index.ts // Database connectie
│ ├── schema/
│ │ ├── users.ts // Users tabel
│ │ ├── posts.ts // Posts tabel
│ │ ├── comments.ts // Comments tabel
│ │ ├── categories.ts // Categories tabel
│ │ ├── relations.ts // Relatie-definities
│ │ └── index.ts // Barrel export
│ └── migrations/ // Gegenereerde migraties
├── data-access/
│ ├── posts.ts // Data Access Layer voor posts
│ ├── users.ts // Data Access Layer voor users
│ └── comments.ts // Data Access Layer voor comments
└── lib/
└── utils.ts
Drizzle configuratie
Maak een drizzle.config.ts bestand in de root van je project. Dit vertelt Drizzle Kit waar je schema staat en hoe de database bereikt kan worden.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./src/db/migrations",
schema: "./src/db/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
De verbose optie geeft je gedetailleerde output bij migraties, en strict zorgt ervoor dat Drizzle Kit om bevestiging vraagt bij destructieve wijzigingen. In een professionele omgeving wil je dat echt altijd aan hebben staan — geloof me, het scheelt je een hoop hoofdpijn.
De Database Connectie: Singleton Pattern en Connection Pooling
Laten we eerlijk zijn: een van de meest onderschatte onderdelen van een database-integratie is het correct opzetten van de connectie. In een serverless omgeving zoals Vercel is dit extra belangrijk, want elke function invocation kan potentieel een nieuwe connectie openen.
De basis connectie met Neon
// src/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
// Type export voor gebruik in de hele applicatie
export type Database = typeof db;
Singleton Pattern voor ontwikkeling
In ontwikkelmodus herlaadt Next.js je modules constant door Hot Module Replacement. Zonder singleton pattern maak je bij elke herlaadbeurt een nieuwe database-connectie aan. Dat leidt al snel tot de beruchte "too many connections" foutmelding. (Als je ooit een uur hebt zitten debuggen waarom je app ineens crashte na een paar saves — dit was waarschijnlijk de boosdoener.)
Hier is de robuuste variant:
// src/db/index.ts
import { neon, Pool } from "@neondatabase/serverless";
import { drizzle as drizzleHttp } from "drizzle-orm/neon-http";
import { drizzle as drizzlePool } from "drizzle-orm/neon-serverless";
import * as schema from "./schema";
// HTTP variant — ideaal voor serverless (geen persistente connectie)
function createHttpClient() {
const sql = neon(process.env.DATABASE_URL!);
return drizzleHttp(sql, { schema });
}
// Pool variant — voor wanneer je transacties nodig hebt
function createPoolClient() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
return drizzlePool(pool, { schema });
}
// Singleton voor ontwikkeling
const globalForDb = globalThis as unknown as {
db: ReturnType<typeof createHttpClient> | undefined;
dbPool: ReturnType<typeof createPoolClient> | undefined;
};
export const db = globalForDb.db ?? createHttpClient();
export const dbPool = globalForDb.dbPool ?? createPoolClient();
if (process.env.NODE_ENV !== "production") {
globalForDb.db = db;
globalForDb.dbPool = dbPool;
}
Let op dat we twee varianten exporteren. De db (HTTP) variant is stateless en perfect voor simpele queries in serverless functies. De dbPool variant houdt een connectiepool aan en is nodig wanneer je transacties wilt gebruiken.
In de praktijk gebruik je db voor zo'n 90% van je queries en dbPool alleen wanneer je expliciet transacties nodig hebt.
Omgevingsvariabelen
Voeg de database URL toe aan je .env.local:
# .env.local
DATABASE_URL="postgresql://gebruiker:[email protected]/neondb?sslmode=require"
Vergeet niet om .env.local aan je .gitignore toe te voegen als dat nog niet het geval is. Database credentials horen nooit in je repository — dat klinkt misschien als een open deur, maar je zou versteld staan hoe vaak het toch misgaat.
Schema Definitie met TypeScript
Oké, dit is waar Drizzle echt schittert. Je definieert je database-schema puur in TypeScript, en Drizzle leidt daar automatisch alle types uit af. Geen codegeneratie, geen aparte schema-taal — gewoon TypeScript.
We bouwen een realistisch blogplatform met gebruikers, posts, reacties en categorieën.
Users tabel
// src/db/schema/users.ts
import {
pgTable,
varchar,
text,
timestamp,
pgEnum,
integer,
boolean,
} from "drizzle-orm/pg-core";
// Enum voor gebruikersrollen (herkenbaar uit ons Auth.js artikel)
export const userRoleEnum = pgEnum("user_role", [
"user",
"author",
"admin",
]);
export const users = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(),
emailVerified: boolean().default(false).notNull(),
passwordHash: text(),
image: text(),
role: userRoleEnum().default("user").notNull(),
bio: text(),
createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp({ withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
Merk op dat we integer().generatedAlwaysAsIdentity() gebruiken in plaats van het oudere serial type. Dit is de moderne PostgreSQL standaard (beschikbaar sinds PostgreSQL 10) en wordt door het PostgreSQL-team zelf aanbevolen boven serial. Identity columns zijn SQL-standaard, bieden betere controle over sequentiegeneratie, en voorkomen veelvoorkomende problemen met permissies op sequences.
Categories tabel
// src/db/schema/categories.ts
import { pgTable, varchar, text, integer, timestamp } from "drizzle-orm/pg-core";
export const categories = pgTable("categories", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 100 }).notNull().unique(),
slug: varchar({ length: 100 }).notNull().unique(),
description: text(),
createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
});
Posts tabel
// src/db/schema/posts.ts
import {
pgTable,
varchar,
text,
integer,
timestamp,
pgEnum,
boolean,
} from "drizzle-orm/pg-core";
import { users } from "./users";
import { categories } from "./categories";
export const postStatusEnum = pgEnum("post_status", [
"draft",
"published",
"archived",
]);
export const posts = pgTable("posts", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar({ length: 255 }).notNull(),
slug: varchar({ length: 255 }).notNull().unique(),
content: text().notNull(),
excerpt: text(),
status: postStatusEnum().default("draft").notNull(),
featured: boolean().default(false).notNull(),
authorId: integer()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
categoryId: integer().references(() => categories.id, {
onDelete: "set null",
}),
publishedAt: timestamp({ withTimezone: true }),
createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp({ withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
Comments tabel
// src/db/schema/comments.ts
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
import { users } from "./users";
import { posts } from "./posts";
export const comments = pgTable("comments", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
content: text().notNull(),
authorId: integer()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
postId: integer()
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
parentId: integer(), // Voor geneste reacties
createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp({ withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
Barrel export
// src/db/schema/index.ts
export { users, userRoleEnum } from "./users";
export { categories } from "./categories";
export { posts, postStatusEnum } from "./posts";
export { comments } from "./comments";
export { relations } from "./relations";
De $onUpdate helper is trouwens een handige Drizzle-feature die automatisch de updatedAt timestamp bijwerkt bij elke wijziging. Dit gebeurt op applicatieniveau (niet via een database trigger), wat het transparant en makkelijk testbaar houdt.
Relaties Definiëren met de Nieuwe RQBv2 API
Drizzle biedt twee manieren om data op te halen: de SQL-achtige query builder (met joins) en de Relational Query Builder (RQB). Met de aankomende v1 release introduceert Drizzle de RQBv2 API met defineRelations — een krachtigere en flexibelere manier om relaties te definiëren.
En eerlijk? Dit is een van de features waar ik het meest enthousiast over ben.
Relaties definiëren met defineRelations
// src/db/schema/relations.ts
import { defineRelations } from "drizzle-orm";
import { users } from "./users";
import { posts } from "./posts";
import { comments } from "./comments";
import { categories } from "./categories";
export const relations = defineRelations(
[users, posts, comments, categories],
({ one, many }) => ({
users: {
posts: many(posts, {
from: users.id,
to: posts.authorId,
}),
comments: many(comments, {
from: users.id,
to: comments.authorId,
}),
},
posts: {
author: one(users, {
from: posts.authorId,
to: users.id,
}),
category: one(categories, {
from: posts.categoryId,
to: categories.id,
}),
comments: many(comments, {
from: posts.id,
to: comments.postId,
}),
},
comments: {
author: one(users, {
from: comments.authorId,
to: users.id,
}),
post: one(posts, {
from: comments.postId,
to: posts.id,
}),
parent: one(comments, {
from: comments.parentId,
to: comments.id,
}),
replies: many(comments, {
from: comments.id,
to: comments.parentId,
}),
},
categories: {
posts: many(posts, {
from: categories.id,
to: posts.categoryId,
}),
},
})
);
Het grote voordeel van defineRelations ten opzichte van de oudere relations() API is dat relaties nu eenzijdig gedefinieerd kunnen worden. Je hoeft niet meer bij beide tabellen een relatie te specificeren — Drizzle leidt de omgekeerde relatie automatisch af. De expliciete from/to syntax maakt het bovendien kristalhelder welke kolommen aan elkaar gekoppeld zijn.
Geneste data opvragen
Met de relaties op hun plek kun je nu elegant geneste queries schrijven:
// Een post ophalen met auteur, categorie en reacties
const postMetDetails = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.slug, "mijn-eerste-post"),
with: {
author: {
columns: {
id: true,
name: true,
image: true,
},
},
category: true,
comments: {
with: {
author: {
columns: {
id: true,
name: true,
image: true,
},
},
},
orderBy: (comments, { desc }) => [desc(comments.createdAt)],
},
},
});
Dit is de kracht van de Relational Query Builder. Je beschrijft wat je wilt ophalen, inclusief geneste relaties, en Drizzle vertaalt het naar efficiënte SQL-queries. Geen N+1 problemen, geen handmatige joins — het werkt gewoon.
De Data Access Layer: Beveiliging als Architectuur
Dit is misschien wel het belangrijkste hoofdstuk van dit hele artikel. Serieus.
Als je ons eerdere artikel over Auth.js v5 hebt gelezen, weet je al dat authenticatie cruciaal is. Maar waar je die authenticatie-checks plaatst, maakt het verschil tussen een veilige en een kwetsbare applicatie.
Waarom middleware niet genoeg is
In ons artikel over middleware hebben we besproken hoe je authenticatie in middleware kunt afhandelen. Dat is prima voor route-beveiliging op hoog niveau, maar het is niet voldoende als enige beveiligingslaag.
De kwetsbaarheid CVE-2025-29927 in Next.js maakte pijnlijk duidelijk waarom: een aanvaller kon middleware volledig omzeilen door een specifieke header mee te sturen. Als je authenticatie-checks alléén in middleware stonden, was je applicatie volledig open. Ouch.
De les? Defense in depth. Beveiligingschecks horen op meerdere plekken, en de meest kritische plek is zo dicht mogelijk bij de data: in je Data Access Layer.
Het DAL-patroon
Het Data Access Layer patroon, aanbevolen door het Next.js-team zelf, creëert een duidelijke grens tussen je applicatiecode en je database. Alle database-operaties gaan via de DAL, en de DAL controleert altijd of de gebruiker geautoriseerd is.
// src/data-access/posts.ts
import "server-only";
import { db } from "@/db";
import { posts, comments } from "@/db/schema";
import { eq, desc, and, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
// Helper om de huidige gebruiker te verifiëren
async function getCurrentUser() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session.user;
}
// Helper om auteur-rechten te controleren
async function verifyAuthor() {
const user = await getCurrentUser();
if (user.role !== "author" && user.role !== "admin") {
throw new Error("Onvoldoende rechten: auteur of admin vereist");
}
return user;
}
// === LEES-OPERATIES ===
export async function getPublishedPosts(
page: number = 1,
pageSize: number = 10
) {
const offset = (page - 1) * pageSize;
const results = await db.query.posts.findMany({
where: (posts, { eq }) => eq(posts.status, "published"),
with: {
author: {
columns: { id: true, name: true, image: true },
},
category: true,
},
orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
limit: pageSize,
offset: offset,
});
return results;
}
export async function getPostBySlug(slug: string) {
const post = await db.query.posts.findFirst({
where: (posts, { and, eq }) =>
and(eq(posts.slug, slug), eq(posts.status, "published")),
with: {
author: {
columns: { id: true, name: true, image: true, bio: true },
},
category: true,
comments: {
with: {
author: {
columns: { id: true, name: true, image: true },
},
},
orderBy: (comments, { desc }) => [desc(comments.createdAt)],
},
},
});
return post;
}
export async function getMyDrafts() {
const user = await verifyAuthor();
return db.query.posts.findMany({
where: (posts, { and, eq }) =>
and(
eq(posts.authorId, Number(user.id)),
eq(posts.status, "draft")
),
orderBy: (posts, { desc }) => [desc(posts.updatedAt)],
});
}
// === SCHRIJF-OPERATIES ===
export async function createPost(data: {
title: string;
slug: string;
content: string;
excerpt?: string;
categoryId?: number;
}) {
const user = await verifyAuthor();
const [newPost] = await db
.insert(posts)
.values({
...data,
authorId: Number(user.id),
status: "draft",
})
.returning();
return newPost;
}
export async function updatePost(
postId: number,
data: {
title?: string;
content?: string;
excerpt?: string;
categoryId?: number;
status?: "draft" | "published" | "archived";
}
) {
const user = await verifyAuthor();
const existingPost = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.id, postId),
});
if (!existingPost) {
throw new Error("Post niet gevonden");
}
if (
existingPost.authorId !== Number(user.id) &&
user.role !== "admin"
) {
throw new Error("Je kunt alleen je eigen posts bewerken");
}
const publishedAt =
data.status === "published" &&
existingPost.status !== "published"
? new Date()
: undefined;
const [updatedPost] = await db
.update(posts)
.set({
...data,
...(publishedAt && { publishedAt }),
})
.where(eq(posts.id, postId))
.returning();
return updatedPost;
}
export async function deletePost(postId: number) {
const user = await verifyAuthor();
const existingPost = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.id, postId),
});
if (!existingPost) {
throw new Error("Post niet gevonden");
}
if (
existingPost.authorId !== Number(user.id) &&
user.role !== "admin"
) {
throw new Error("Je kunt alleen je eigen posts verwijderen");
}
await db.delete(posts).where(eq(posts.id, postId));
}
Let op de import "server-only" bovenaan het bestand. Dit is een Next.js conventie die ervoor zorgt dat dit bestand nooit per ongeluk in een client bundle terechtkomt. Als iemand het probeert te importeren in een Client Component, krijg je direct een build-error. Klein detail, maar een essentiële beveiligingsmaatregel voor je DAL.
DAL voor reacties
// src/data-access/comments.ts
import "server-only";
import { db } from "@/db";
import { comments } from "@/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
async function getCurrentUser() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session.user;
}
export async function createComment(data: {
content: string;
postId: number;
parentId?: number;
}) {
const user = await getCurrentUser();
const [newComment] = await db
.insert(comments)
.values({
content: data.content,
postId: data.postId,
parentId: data.parentId,
authorId: Number(user.id),
})
.returning();
return newComment;
}
export async function deleteComment(commentId: number) {
const user = await getCurrentUser();
const comment = await db.query.comments.findFirst({
where: (comments, { eq }) => eq(comments.id, commentId),
});
if (!comment) {
throw new Error("Reactie niet gevonden");
}
if (comment.authorId !== Number(user.id)) {
const session = await auth();
if (session?.user?.role !== "admin") {
throw new Error("Je kunt alleen je eigen reacties verwijderen");
}
}
await db.delete(comments).where(eq(comments.id, commentId));
}
Het DAL-patroon geeft je een enkele plek waar al je beveiligingslogica leeft. Dit maakt het praktisch onmogelijk om per ongeluk een onbeveiligde database-query te schrijven in een Server Component of Server Action. Alles gaat via de DAL, en de DAL verifieert altijd autorisatie. Zo simpel is het.
CRUD Operaties met Server Components en Server Actions
Nu we een solide DAL hebben, is het tijd om het daadwerkelijk te gebruiken in onze Next.js applicatie. Als je ons eerdere artikel over Server Components en data ophalen hebt gelezen, zul je zien hoe mooi alles in elkaar past.
Data lezen in Server Components
// src/app/(blog)/posts/page.tsx
import { getPublishedPosts } from "@/data-access/posts";
import { PostCard } from "@/components/post-card";
import { Suspense } from "react";
interface SearchParams {
page?: string;
}
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
const page = Number(params.page) || 1;
return (
<main>
<h1>Blog</h1>
<Suspense fallback={<PostsSkeletons />}>
<PostsList page={page} />
</Suspense>
</main>
);
}
async function PostsList({ page }: { page: number }) {
const posts = await getPublishedPosts(page);
if (posts.length === 0) {
return <p>Nog geen posts gevonden.</p>;
}
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Het mooie hiervan is dat Server Components direct async/await kunnen gebruiken. Geen useEffect, geen useState, geen loading states handmatig beheren — Suspense handelt dat allemaal af. Precies zoals we in het eerste artikel van deze serie besproken hebben.
Mutaties met Server Actions en Zod-validatie
Voor het aanmaken en bewerken van data gebruiken we Server Actions. In ons eerdere artikel over Server Actions en formulieren hebben we de basis behandeld. Nu voegen we Zod-validatie toe met de drizzle-zod integratie — en dit is waar het echt leuk wordt.
Installeer eerst de integratie:
npm install drizzle-zod zod
Nu kunnen we validatieschema's automatisch genereren uit onze Drizzle-tabeldefinities:
// src/lib/validations/posts.ts
import { createInsertSchema, createUpdateSchema } from "drizzle-zod";
import { posts } from "@/db/schema";
import { z } from "zod";
export const insertPostSchema = createInsertSchema(posts, {
title: z
.string()
.min(3, "Titel moet minimaal 3 tekens bevatten")
.max(255, "Titel mag maximaal 255 tekens bevatten"),
slug: z
.string()
.min(3)
.max(255)
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Slug mag alleen kleine letters, cijfers en streepjes bevatten"
),
content: z.string().min(50, "Inhoud moet minimaal 50 tekens bevatten"),
excerpt: z.string().max(500).optional(),
}).omit({
id: true,
authorId: true,
createdAt: true,
updatedAt: true,
publishedAt: true,
});
export type InsertPostInput = z.infer<typeof insertPostSchema>;
Het mooie van drizzle-zod is dat je Zod-schema direct gekoppeld is aan je database-schema. Als je een kolom toevoegt aan je tabel, is die automatisch beschikbaar in je validatieschema. Eén bron van waarheid — en dat scheelt een hoop synchronisatie-gedoe.
Server Actions definiëren
// src/app/(blog)/posts/actions.ts
"use server";
import { createPost, updatePost, deletePost } from "@/data-access/posts";
import { insertPostSchema } from "@/lib/validations/posts";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createPostAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const rawData = {
title: formData.get("title") as string,
slug: formData.get("slug") as string,
content: formData.get("content") as string,
excerpt: (formData.get("excerpt") as string) || undefined,
categoryId: formData.get("categoryId")
? Number(formData.get("categoryId"))
: undefined,
};
const validationResult = insertPostSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
message: "Validatie mislukt",
errors: validationResult.error.flatten().fieldErrors as Record<string, string[]>,
};
}
try {
const post = await createPost(validationResult.data);
revalidatePath("/posts");
redirect(`/posts/${post.slug}`);
} catch (error) {
if (error instanceof Error && error.message.includes("unique")) {
return {
success: false,
message: "Deze slug is al in gebruik. Kies een andere slug.",
};
}
return {
success: false,
message: "Er is iets misgegaan bij het aanmaken van de post.",
};
}
}
export async function deletePostAction(
postId: number
): Promise<ActionState> {
try {
await deletePost(postId);
revalidatePath("/posts");
return { success: true, message: "Post succesvol verwijderd." };
} catch (error) {
return {
success: false,
message:
error instanceof Error
? error.message
: "Er is iets misgegaan bij het verwijderen.",
};
}
}
Merk op hoe de Server Actions zelf geen directe database-operaties bevatten. Ze valideren input, roepen de DAL aan, en handelen cache-invalidatie af. Deze scheiding van verantwoordelijkheden maakt je code testbaar, veilig en onderhoudbaar.
Het formulier in een Client Component
// src/components/create-post-form.tsx
"use client";
import { useActionState } from "react";
import { createPostAction, type ActionState } from "@/app/(blog)/posts/actions";
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState<
ActionState | null,
FormData
>(createPostAction, null);
return (
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="title">Titel</label>
<input
id="title"
name="title"
type="text"
required
className="w-full rounded border p-2"
/>
{state?.errors?.title && (
<p className="text-sm text-red-600">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="slug">Slug</label>
<input
id="slug"
name="slug"
type="text"
required
className="w-full rounded border p-2"
pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
/>
</div>
<div>
<label htmlFor="content">Inhoud</label>
<textarea
id="content"
name="content"
required
rows={12}
className="w-full rounded border p-2"
/>
</div>
{state && !state.success && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-red-800">
{state.message}
</div>
)}
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Opslaan..." : "Post aanmaken"}
</button>
</form>
);
}
Migraties Beheren met Drizzle Kit
Drizzle Kit is de CLI-tool die je schema-definities omzet naar SQL-migraties. Het biedt drie hoofdcommando's die je dagelijks zult gebruiken.
drizzle-kit generate
Dit commando vergelijkt je huidige TypeScript-schema met de vorige migraties en genereert een nieuw migratiebestand:
npx drizzle-kit generate
Drizzle Kit maakt dan een SQL-bestand aan in je migrations-map met een timestamp en beschrijvende naam. Dit bestand bevat pure SQL die je kunt inspecteren, bewerken en in versiebeheer kunt opnemen.
drizzle-kit migrate
Voer de gegenereerde migraties uit op je database:
npx drizzle-kit migrate
Dit commando houdt bij welke migraties al zijn uitgevoerd via een __drizzle_migrations tabel in je database. Alleen nieuwe migraties worden toegepast — je hoeft dus niet bang te zijn dat iets dubbel draait.
drizzle-kit push
Voor snelle ontwikkeling kun je push gebruiken. Dit synchroniseert je schema direct naar de database zonder migratiebestanden aan te maken:
npx drizzle-kit push
Belangrijk: gebruik push alleen tijdens lokale ontwikkeling of prototyping. Voor productie wil je altijd generate en migrate gebruiken, zodat je volledige controle en versiegeschiedenis hebt over je schema-wijzigingen.
Drizzle Studio
Een van de leukste extra's van Drizzle Kit is Drizzle Studio, een webgebaseerde database-browser:
npx drizzle-kit studio
Dit opent een lokale webinterface op https://local.drizzle.studio waar je je database kunt inspecteren, data kunt bekijken en zelfs handmatig records kunt toevoegen of bewerken. Erg handig tijdens ontwikkeling wanneer je snel wilt controleren of je migratie correct is uitgevoerd of testdata wilt invoeren. Ik gebruik het zelf constant.
Handig npm script setup
Voeg deze scripts toe aan je package.json voor een soepele workflow:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
}
}
Een seed script
Voor ontwikkeling is het enorm handig om een seed script te hebben dat testdata aanmaakt. Niets is vervelender dan elke keer handmatig data invoeren na een verse migratie:
// src/db/seed.ts
import { db } from "./index";
import { users, categories, posts, comments } from "./schema";
async function seed() {
console.log("Database seeding gestart...");
const [techCategorie, designCategorie] = await db
.insert(categories)
.values([
{
name: "Technologie",
slug: "technologie",
description: "Technische artikelen",
},
{
name: "Design",
slug: "design",
description: "Design gerelateerde content",
},
])
.returning();
const [testUser] = await db
.insert(users)
.values({
name: "Jan de Ontwikkelaar",
email: "[email protected]",
role: "author",
bio: "Full-stack developer met een passie voor Next.js",
})
.returning();
const [post1] = await db
.insert(posts)
.values({
title: "Aan de slag met Next.js",
slug: "aan-de-slag-met-nextjs",
content: "Een uitgebreid artikel over Next.js...",
excerpt: "Ontdek wat er nieuw is in Next.js",
status: "published",
featured: true,
authorId: testUser.id,
categoryId: techCategorie.id,
publishedAt: new Date(),
})
.returning();
await db.insert(comments).values({
content: "Geweldig artikel! Heel nuttig.",
authorId: testUser.id,
postId: post1.id,
});
console.log("Database seeding voltooid!");
}
seed().catch(console.error);
Prestatie-optimalisatie en Best Practices
Eerlijk gezegd komen de meeste prestatieproblemen met databases niet van de ORM zelf, maar van hoe je queries schrijft. Hier zijn de belangrijkste optimalisatietechnieken die ik met Drizzle heb geleerd.
Selecteer alleen wat je nodig hebt
Selecteer nooit blindelings alle kolommen als je maar een paar velden nodig hebt. Dit is vooral belangrijk bij tabellen met grote text-kolommen (denk aan volledige blogposts):
// Slecht: haalt alle kolommen op inclusief de volledige content
const allPosts = await db.query.posts.findMany();
// Goed: haalt alleen de benodigde velden op
const postSummaries = await db.query.posts.findMany({
columns: {
id: true,
title: true,
slug: true,
excerpt: true,
publishedAt: true,
featured: true,
},
with: {
author: {
columns: { name: true, image: true },
},
},
});
Paginatie patroon
Voor efficiënte paginatie combineer je limit, offset, en een totaaltelling. De truc hier is om beide queries parallel uit te voeren met Promise.all:
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, count } from "drizzle-orm";
export async function getPaginatedPosts(
page: number = 1,
pageSize: number = 10
) {
const offset = (page - 1) * pageSize;
// Voer de data-query en de count-query parallel uit
const [results, totalResult] = await Promise.all([
db.query.posts.findMany({
where: (posts, { eq }) => eq(posts.status, "published"),
columns: {
id: true,
title: true,
slug: true,
excerpt: true,
publishedAt: true,
},
with: {
author: { columns: { name: true, image: true } },
category: { columns: { name: true, slug: true } },
},
orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
limit: pageSize,
offset: offset,
}),
db
.select({ total: count() })
.from(posts)
.where(eq(posts.status, "published")),
]);
const total = totalResult[0].total;
const totalPages = Math.ceil(total / pageSize);
return {
posts: results,
pagination: {
page,
pageSize,
total,
totalPages,
hasNext: page < totalPages,
hasPrevious: page > 1,
},
};
}
Transacties
Gebruik transacties wanneer je meerdere operaties atomair wilt uitvoeren. Hiervoor heb je de pool-client nodig:
import { dbPool } from "@/db";
import { posts, comments } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function deletePostWithCleanup(postId: number) {
return dbPool.transaction(async (tx) => {
await tx.delete(comments).where(eq(comments.postId, postId));
const [deletedPost] = await tx
.delete(posts)
.where(eq(posts.id, postId))
.returning();
if (!deletedPost) {
tx.rollback();
throw new Error("Post niet gevonden");
}
return deletedPost;
});
}
Error handling
Tot slot, maak een herbruikbare error handler voor database-operaties. PostgreSQL stuurt specifieke foutcodes mee die je kunt vertalen naar gebruiksvriendelijke meldingen:
// src/lib/db-error-handler.ts
export function handleDatabaseError(error: unknown): never {
if (error instanceof Error && "code" in error) {
const dbError = error as Error & { code: string };
switch (dbError.code) {
case "23505": // unique_violation
throw new Error(
"Deze waarde bestaat al. Gebruik een unieke waarde."
);
case "23503": // foreign_key_violation
throw new Error(
"Dit item kan niet verwijderd worden: er bestaat gerelateerde data."
);
case "23502": // not_null_violation
throw new Error("Een verplicht veld ontbreekt.");
default:
console.error("Database error:", dbError.code, dbError.message);
throw new Error("Er is een databasefout opgetreden.");
}
}
console.error("Onverwachte fout:", error);
throw new Error("Er is een onverwachte fout opgetreden.");
}
Deployment naar Vercel met Neon PostgreSQL
Je applicatie lokaal laten draaien is één ding, maar uiteindelijk moet alles naar productie. De combinatie van Vercel en Neon PostgreSQL is momenteel de meest populaire keuze voor Next.js-applicaties — en eerlijk gezegd snap ik wel waarom.
Neon PostgreSQL opzetten
Neon is een serverless PostgreSQL-provider die specifiek ontworpen is voor moderne cloud-applicaties. Het opzetten gaat verrassend snel:
- Maak een account aan op neon.tech.
- Maak een nieuw project aan en kies de eu-central-1 regio (voor de laagste latency vanuit Nederland en België).
- Kopieer de connection string uit het Neon dashboard.
Neon biedt twee typen connection strings:
- Pooled connection — gaat via een ingebouwde PgBouncer. Gebruik dit voor je serverless functies.
- Direct connection — een directe verbinding naar de database. Gebruik dit voor migraties.
Vercel omgevingsvariabelen
Stel de volgende omgevingsvariabelen in via het Vercel dashboard (Settings > Environment Variables):
# Pooled connection voor de applicatie
DATABASE_URL="postgresql://user:[email protected]/neondb?sslmode=require"
# Direct connection voor migraties
DATABASE_URL_DIRECT="postgresql://user:[email protected]/neondb?sslmode=require"
Als je Vercel gebruikt, kun je ook de Neon-integratie vanuit de Vercel marketplace installeren. Die vult de omgevingsvariabelen automatisch in en geeft je een naadloze ervaring met preview branches die hun eigen database-branch krijgen. Best handig als je in een team werkt.
Serverless connectie-overwegingen
In een serverless omgeving werkt database-connectiviteit fundamenteel anders dan bij een traditionele server. Elke function invocation kan een nieuwe connectie openen, en bij veel verkeer loop je snel tegen connectielimieten aan.
Hier zijn de belangrijkste punten om in gedachten te houden:
- Gebruik altijd de pooled connection — Neon's ingebouwde connection pooler beheert connecties efficiënt en voorkomt dat je tegen limieten aanloopt.
- Kies de HTTP-driver voor simpele queries — De Neon HTTP-driver maakt geen persistente connectie. Elke query is een enkel HTTP-verzoek. Ideaal voor serverless.
- Gebruik de WebSocket-driver voor transacties — Als je transacties nodig hebt, moet je de WebSocket-driver gebruiken. Alleen via een persistente connectie kun je een transactie openhouden.
Onze eerder opgestelde db/index.ts houdt hier al rekening mee door zowel een HTTP-client als een Pool-client te exporteren.
Migraties in de CI/CD pipeline
Voer migraties uit als onderdeel van je build-proces. Hier is een simpel maar effectief voorbeeld:
{
"scripts": {
"build": "npm run db:migrate && next build",
"db:migrate": "drizzle-kit migrate"
}
}
Zorg ervoor dat je voor migraties de directe connection string gebruikt (niet de gepoolde). Je kunt dit doen door de omgevingsvariabele conditioneel in te stellen in je configuratie:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./src/db/migrations",
schema: "./src/db/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL_DIRECT ?? process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Conclusie
We hebben in dit artikel een behoorlijke reis gemaakt: van het installeren van Drizzle ORM tot een productie-ready deployment met Neon PostgreSQL op Vercel. Even de kernpunten op een rij:
- Drizzle ORM biedt type-safe database-interactie zonder codegeneratie, met een SQL-first filosofie die aansluit bij wat je al kent.
- Identity columns zijn de moderne standaard voor auto-incrementing primary keys in PostgreSQL — gebruik ze in plaats van
serial. - De RQBv2 API met
defineRelationsmaakt het definiëren en opvragen van relaties intuïtief en krachtig. - De Data Access Layer is geen optioneel patroon maar een architecturale noodzaak. Na CVE-2025-29927 weten we dat middleware alleen niet voldoende is — beveiligingschecks horen bij de data.
- drizzle-zod integreert je database-schema naadloos met je validatielogica, zodat je één bron van waarheid hebt.
- Neon + Vercel is een krachtige combinatie voor serverless PostgreSQL, mits je rekening houdt met het onderscheid tussen HTTP en pooled connecties.
Met dit vijfde deel hebben we nu een uitgebreide serie die het complete spectrum dekt van een moderne Next.js-applicatie: data ophalen en streamen, formulieren en Server Actions, middleware en beveiliging, authenticatie met Auth.js, en nu database-integratie met Drizzle ORM.
Mijn advies? Begin klein. Eén tabel, één DAL-functie, één Server Component die data ophaalt. Bouw vanuit daar incrementeel verder op. De architectuurpatronen die we hier besproken hebben, schalen mee met je applicatie — van een simpel hobbyproject tot een volwaardige productie-applicatie.
Drizzle ORM evolueert snel, met de v1 release in aantocht en nieuwe features die regelmatig worden toegevoegd. Houd de officiële documentatie op orm.drizzle.team in de gaten voor de nieuwste ontwikkelingen. En eerlijk? Als je eenmaal gewend bent aan de developer experience van Drizzle, is er geen weg meer terug.