Si vous bossez avec Next.js en 2026, vous avez probablement remarqué un truc : la doc officielle de Next.js 16 recommande maintenant très clairement d'utiliser un Data Access Layer (DAL) dès que votre app manipule des données sensibles. Et c'est pas juste un "conseil sympa" qu'on peut ignorer — c'est devenu un vrai patron d'architecture, quasi incontournable pour sécuriser une application moderne. Concrètement, un Data Access Layer, c'est une couche d'abstraction qui centralise tous vos accès à la base de données, applique les vérifications d'autorisation, et s'assure que seules des données filtrées et propres arrivent jusqu'à vos composants et vos clients.
Dans ce guide, on va construire un Data Access Layer solide avec Next.js 16, Drizzle ORM, TypeScript et Zod. Je vous montre pourquoi cette couche est devenue indispensable, comment la structurer correctement, et comment l'intégrer avec les Server Components et les Server Actions. Tous les exemples de code sont fonctionnels — vous pouvez les reprendre et les adapter à vos projets.
Pourquoi un Data Access Layer ?
Bon, passons aux choses sérieuses. Sans Data Access Layer, les apps Next.js finissent toujours par accumuler les mêmes problèmes. Et croyez-moi, ça devient vraiment critique quand le projet grossit. Voici ce que le DAL résout :
- Requêtes dispersées dans le code — Les appels à la base de données se retrouvent un peu partout : Server Components, Server Actions, Route Handlers, et parfois même dans des utilitaires partagés. Résultat ? La moindre modif de schéma vous oblige à fouiller des dizaines de fichiers. Pas fun.
- Bugs d'autorisation — Quand chaque composant fait sa propre vérification d'authentification, c'est inévitable : un jour, quelqu'un oublie un contrôle. Un seul oubli et des données sensibles se retrouvent exposées (et croyez-moi, ça arrive plus souvent qu'on ne le pense).
- Exposition accidentelle de données au client — Sans couche de filtrage, un objet utilisateur complet — avec le hash du mot de passe, les tokens internes, tout le bazar — peut se retrouver sérialisé dans le HTML ou transmis à un composant client. J'ai personnellement vu ça sur un projet en production. Pas glorieux.
- Contrôles de sécurité inconsistants — Un endpoint vérifie le rôle admin, un autre l'oublie. Différentes parties de l'app appliquent des règles différentes. Le DAL centralise tout ça en un seul endroit.
- Difficulté de test — Quand la logique d'accès aux données est mélangée avec la logique de présentation, les tests unitaires deviennent un cauchemar. Fragiles, complexes, pénibles à maintenir.
Le Data Access Layer résout tous ces problèmes en créant un point de passage unique et obligatoire entre votre application et votre base de données. Chaque fonction du DAL vérifie l'authentification, valide les paramètres, interroge la base, et retourne un objet propre et sécurisé. Simple, prévisible, testable.
Architecture et Structure du Projet
Avant d'écrire la moindre ligne de code, posons la structure de dossiers. C'est le genre de chose qu'on a tendance à négliger au début, mais une bonne organisation dès le départ vous fera gagner un temps fou par la suite :
/app
/dashboard
page.tsx # Server Component utilisant le DAL
/blog
/[slug]
page.tsx
layout.tsx
/lib
/dal # Data Access Layer
index.ts # Point d'entree du DAL
users.ts # Fonctions DAL pour les utilisateurs
posts.ts # Fonctions DAL pour les articles
auth.ts # Verification d'authentification
errors.ts # Classes d'erreurs personnalisees
/db # Configuration base de donnees
index.ts # Connexion Drizzle
schema.ts # Schema de la base de donnees
/types # DTOs et types TypeScript
user.ts
post.ts
/components
/dashboard
/blog
/actions # Server Actions
post-actions.ts
Le dossier /lib/dal, c'est le coeur de toute l'architecture. La règle est simple : aucun composant, aucune action ne doit interroger la base de données directement. Tout passe par les fonctions exportées depuis ce dossier. Point final.
Configuration de la Base de Données avec Drizzle ORM
Drizzle ORM, c'est un ORM TypeScript léger et performant qui s'intègre vraiment bien avec Next.js. Honnêtement, c'est devenu mon choix par défaut sur les nouveaux projets. Commencez par installer les dépendances :
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
Définition du Schéma
On crée le fichier de schéma avec une table users et une table posts, reliées entre elles. Rien de très surprenant ici :
// lib/db/schema.ts
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["user", "admin"] })
.default("user")
.notNull(),
emailVerified: boolean("email_verified").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
content: text("content").notNull(),
slug: text("slug").notNull().unique(),
published: boolean("published").default(false).notNull(),
authorId: uuid("author_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Connexion à la Base de Données
Ensuite, on configure la connexion PostgreSQL. Ici j'utilise Neon comme fournisseur serverless, mais vous pouvez adapter ça à n'importe quel fournisseur PostgreSQL sans problème :
// lib/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 });
export type Database = typeof db;
Protéger le DAL avec server-only
Alors, celui-là c'est non négociable. Le package server-only garantit que votre code DAL ne sera jamais inclus dans un bundle client. Si quelqu'un dans votre équipe importe par mégarde une fonction du DAL dans un composant client, le build plante immédiatement avec une erreur claire. C'est brutal, mais c'est exactement ce qu'on veut.
npm install server-only
Ajoutez l'import en haut de chaque fichier du DAL :
// lib/dal/index.ts
import "server-only";
// Toutes les exportations du DAL
export { getUser, getUserById } from "./users";
export { getUserPosts, createPost, updatePost } from "./posts";
export { verifySession } from "./auth";
Avec ça, même si un collègue (on a tous ce collègue...) tente d'importer getUser dans un composant marqué "use client", le compilateur Next.js refusera de construire le projet. C'est votre première ligne de défense contre les fuites de données.
Vérification d'Authentification avec React.cache()
La fonction verifySession(), c'est le gardien de votre DAL. Elle vérifie que l'utilisateur est bien authentifié avant chaque accès aux données. Et ici, l'utilisation de React.cache() est vraiment cruciale — elle garantit que la vérification ne s'exécute qu'une seule fois par requête HTTP, même si plusieurs composants ou fonctions du DAL l'appellent pendant le même rendu.
// lib/dal/auth.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { jwtVerify } from "jose";
export type SessionPayload = {
userId: string;
role: "user" | "admin";
expiresAt: Date;
};
const secretKey = new TextEncoder().encode(process.env.SESSION_SECRET!);
export const verifySession = cache(
async (): Promise<SessionPayload> => {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) {
redirect("/login");
}
try {
const { payload } = await jwtVerify(token, secretKey);
const session: SessionPayload = {
userId: payload.userId as string,
role: payload.role as "user" | "admin",
expiresAt: new Date(payload.exp! * 1000),
};
// Verifier que la session n'est pas expiree
if (session.expiresAt < new Date()) {
redirect("/login");
}
return session;
} catch {
redirect("/login");
}
}
);
Pourquoi React.cache() est si important ici ? Imaginez : votre page appelle getUser() et getUserPosts(), et chacune de ces fonctions appelle verifySession() en interne. Sans cache(), vous vous retrouvez avec deux vérifications JWT et deux lectures de cookies pour rien. Avec cache(), la deuxième invocation retourne instantanément le résultat de la première. Performances améliorées, cohérence garantie. Tout le monde y gagne.
Créer les Fonctions du DAL
Chaque fonction du DAL suit le même schéma : vérifier l'authentification, valider les paramètres, exécuter la requête, retourner un DTO propre. C'est volontairement répétitif. Voyons les fonctions principales.
getUser() — Récupérer l'Utilisateur Courant
// lib/dal/users.ts
import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { verifySession } from "./auth";
import { toUserDTO, type UserDTO } from "@/types/user";
import { DALError } from "./errors";
export async function getUser(): Promise<UserDTO> {
const session = await verifySession();
const user = await db.query.users.findFirst({
where: eq(users.id, session.userId),
});
if (!user) {
throw new DALError("USER_NOT_FOUND", "Utilisateur introuvable");
}
// Ne jamais retourner l'objet brut de la base de donnees
return toUserDTO(user);
}
export async function getUserById(id: string): Promise<UserDTO | null> {
// Verifier que l'appelant est authentifie
await verifySession();
const user = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!user) {
return null;
}
return toUserDTO(user);
}
getUserPosts() — Récupérer les Articles avec Autorisation
// lib/dal/posts.ts
import "server-only";
import { eq, and, desc } from "drizzle-orm";
import { db } from "@/lib/db";
import { posts } from "@/lib/db/schema";
import { verifySession } from "./auth";
import { toPostDTO, type PostDTO } from "@/types/post";
import { DALError } from "./errors";
export async function getUserPosts(): Promise<PostDTO[]> {
const session = await verifySession();
const userPosts = await db.query.posts.findMany({
where: eq(posts.authorId, session.userId),
orderBy: [desc(posts.createdAt)],
});
return userPosts.map(toPostDTO);
}
export async function getPublishedPosts(): Promise<PostDTO[]> {
// Les articles publies sont accessibles a tous les utilisateurs authentifies
await verifySession();
const publishedPosts = await db.query.posts.findMany({
where: eq(posts.published, true),
orderBy: [desc(posts.createdAt)],
with: {
author: true,
},
});
return publishedPosts.map(toPostDTO);
}
createPost() — Créer un Article avec Validation
// lib/dal/posts.ts (suite)
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(3, "Le titre doit contenir au moins 3 caracteres").max(200),
content: z.string().min(10, "Le contenu doit contenir au moins 10 caracteres"),
slug: z.string().regex(/^[a-z0-9-]+$/, "Le slug ne peut contenir que des lettres minuscules, des chiffres et des tirets"),
published: z.boolean().default(false),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
export async function createPost(input: CreatePostInput): Promise<PostDTO> {
const session = await verifySession();
// Valider les donnees entrantes
const validated = createPostSchema.parse(input);
// Verifier l'unicite du slug
const existingPost = await db.query.posts.findFirst({
where: eq(posts.slug, validated.slug),
});
if (existingPost) {
throw new DALError(
"DUPLICATE_SLUG",
"Un article avec ce slug existe deja"
);
}
const [newPost] = await db
.insert(posts)
.values({
title: validated.title,
content: validated.content,
slug: validated.slug,
published: validated.published,
authorId: session.userId,
})
.returning();
return toPostDTO(newPost);
}
updatePost() — Modifier avec Vérification de Propriété
// lib/dal/posts.ts (suite)
const updatePostSchema = z.object({
title: z.string().min(3).max(200).optional(),
content: z.string().min(10).optional(),
published: z.boolean().optional(),
});
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export async function updatePost(
postId: string,
input: UpdatePostInput
): Promise<PostDTO> {
const session = await verifySession();
// Recuperer l'article existant
const existingPost = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
if (!existingPost) {
throw new DALError("POST_NOT_FOUND", "Article introuvable");
}
// Verifier que l'utilisateur est le proprietaire ou un admin
if (
existingPost.authorId !== session.userId &&
session.role !== "admin"
) {
throw new DALError(
"FORBIDDEN",
"Vous n'avez pas la permission de modifier cet article"
);
}
const validated = updatePostSchema.parse(input);
const [updatedPost] = await db
.update(posts)
.set({
...validated,
updatedAt: new Date(),
})
.where(eq(posts.id, postId))
.returning();
return toPostDTO(updatedPost);
}
Vous voyez le patron qui se répète ? Chaque fonction commence par verifySession(), valide les entrées, vérifie les autorisations spécifiques (propriété, rôle), puis retourne un DTO sécurisé. C'est répétitif à dessein. La sécurité, ça doit être prévisible et vérifiable — pas le genre de truc où on fait preuve de "créativité".
Data Transfer Objects (DTOs)
Un DTO, c'est un objet qui définit exactement quelles données sortent du DAL. Règle d'or : ne retournez jamais l'objet brut de Drizzle directement. Jamais. L'objet de la base contient potentiellement des champs sensibles — hash du mot de passe, tokens internes, métadonnées système — qui n'ont absolument rien à faire côté client.
// types/user.ts
import type { InferSelectModel } from "drizzle-orm";
import type { users } from "@/lib/db/schema";
// Type brut de la base de donnees (ne jamais exposer directement)
type UserRecord = InferSelectModel<typeof users>;
// DTO securise pour l'application
export type UserDTO = {
id: string;
name: string;
email: string;
role: "user" | "admin";
emailVerified: boolean;
createdAt: Date;
};
// Fonction de transformation
export function toUserDTO(user: UserRecord): UserDTO {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
};
// passwordHash, updatedAt et autres champs internes sont exclus
}
// types/post.ts
import type { InferSelectModel } from "drizzle-orm";
import type { posts } from "@/lib/db/schema";
type PostRecord = InferSelectModel<typeof posts>;
export type PostDTO = {
id: string;
title: string;
content: string;
slug: string;
published: boolean;
authorId: string;
createdAt: Date;
updatedAt: Date;
};
export function toPostDTO(post: PostRecord): PostDTO {
return {
id: post.id,
title: post.title,
content: post.content,
slug: post.slug,
published: post.published,
authorId: post.authorId,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
Le DTO agit comme un contrat entre votre DAL et le reste de l'application. Si la structure de la base change demain (ajout de colonnes, renommage de champs), seules les fonctions de transformation doivent être mises à jour. Vos composants qui consomment les DTOs ? Ils ne bougent pas. Sur un projet récent, on a renommé une demi-douzaine de colonnes en base et ça n'a touché que les fichiers de transformation. Plutôt satisfaisant.
Intégrer le DAL avec les Server Components
Et c'est là que ça devient vraiment agréable. L'un des gros avantages de cette architecture, c'est la simplicité d'utilisation dans les Server Components. Vos pages appellent simplement les fonctions du DAL comme des fonctions asynchrones ordinaires, sans se soucier de quoi que ce soit d'autre :
// app/dashboard/page.tsx
import { getUser, getUserPosts } from "@/lib/dal";
import { PostCard } from "@/components/dashboard/post-card";
export default async function DashboardPage() {
const user = await getUser();
const posts = await getUserPosts();
return (
<div className="max-w-4xl mx-auto py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold">
Bonjour, {user.name}
</h1>
<p className="text-gray-600 mt-2">
Vous avez {posts.length} article{posts.length !== 1 ? "s" : ""}
</p>
</header>
<section>
<h2 className="text-xl font-semibold mb-4">Vos articles</h2>
{posts.length === 0 ? (
<p className="text-gray-500">
Vous n'avez pas encore publie d'article.
</p>
) : (
<div className="grid gap-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</section>
</div>
);
}
Regardez bien cette page. Elle n'a aucune connaissance de la base de données, de Drizzle, du JWT ou des cookies. Elle appelle getUser() et getUserPosts(), elle reçoit des objets propres et typés, et c'est tout. Si l'utilisateur n'est pas authentifié, verifySession() le redirige vers /login avant même que le composant ne s'affiche. Et grâce à React.cache(), les deux appels à verifySession() — un dans chaque fonction — ne produisent qu'une seule vérification réelle. Élégant.
Intégrer le DAL avec les Server Actions
Les Server Actions, c'est le mécanisme recommandé par Next.js pour les mutations : création, modification, suppression. Elles appellent les fonctions du DAL exactement comme les Server Components, mais en gérant en plus la validation des formulaires et le retour d'erreurs structurées.
// actions/post-actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { createPost, updatePost } from "@/lib/dal";
import { DALError } from "@/lib/dal/errors";
const createPostFormSchema = z.object({
title: z.string().min(3, "Le titre doit contenir au moins 3 caracteres"),
content: z.string().min(10, "Le contenu est trop court"),
slug: z.string().regex(
/^[a-z0-9-]+$/,
"Le slug ne peut contenir que des lettres minuscules, chiffres et tirets"
),
published: z
.string()
.transform((val) => val === "true")
.default("false"),
});
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createPostAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Valider les donnees du formulaire
const rawData = Object.fromEntries(formData);
const result = createPostFormSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
message: "Erreur de validation",
errors: result.error.flatten().fieldErrors,
};
}
try {
await createPost(result.data);
revalidatePath("/dashboard");
return {
success: true,
message: "Article cree avec succes",
};
} catch (error) {
if (error instanceof DALError) {
return {
success: false,
message: error.message,
};
}
return {
success: false,
message: "Une erreur inattendue est survenue",
};
}
}
export async function updatePostAction(
postId: string,
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = Object.fromEntries(formData);
try {
await updatePost(postId, {
title: rawData.title as string | undefined,
content: rawData.content as string | undefined,
published: rawData.published === "true",
});
revalidatePath("/dashboard");
return {
success: true,
message: "Article mis a jour avec succes",
};
} catch (error) {
if (error instanceof DALError) {
if (error.code === "FORBIDDEN") {
return {
success: false,
message: "Vous n'etes pas autorise a modifier cet article",
};
}
return { success: false, message: error.message };
}
return {
success: false,
message: "Une erreur inattendue est survenue",
};
}
}
Cette séparation entre Server Actions et DAL, c'est vraiment le point clé. La Server Action gère la couche formulaire : extraction des données, formatage de la réponse, revalidation du cache. Le DAL gère la couche métier : authentification, autorisation, validation métier, accès base de données. Chaque couche a sa responsabilité. Ni plus, ni moins.
Gestion des Erreurs dans le DAL
On en parle pas assez, mais une bonne gestion d'erreurs dans le DAL, c'est ce qui fait la différence entre un projet maintenable et un projet cauchemardesque. Créez des classes d'erreurs personnalisées qui transportent un code machine et un message lisible :
// lib/dal/errors.ts
export type DALErrorCode =
| "USER_NOT_FOUND"
| "POST_NOT_FOUND"
| "FORBIDDEN"
| "DUPLICATE_SLUG"
| "VALIDATION_ERROR"
| "DATABASE_ERROR";
export class DALError extends Error {
public readonly code: DALErrorCode;
constructor(code: DALErrorCode, message: string) {
super(message);
this.name = "DALError";
this.code = code;
}
}
// Utilitaire pour encapsuler les erreurs de base de donnees
export function wrapDatabaseError(error: unknown): never {
console.error("Erreur base de donnees :", error);
throw new DALError(
"DATABASE_ERROR",
"Une erreur est survenue lors de l'acces aux donnees"
);
}
Ensuite, utilisez wrapDatabaseError dans vos fonctions DAL pour intercepter les erreurs Drizzle ou PostgreSQL et les transformer en erreurs propres :
// Exemple d'utilisation dans une fonction DAL
export async function getUser(): Promise<UserDTO> {
const session = await verifySession();
try {
const user = await db.query.users.findFirst({
where: eq(users.id, session.userId),
});
if (!user) {
throw new DALError("USER_NOT_FOUND", "Utilisateur introuvable");
}
return toUserDTO(user);
} catch (error) {
if (error instanceof DALError) throw error;
wrapDatabaseError(error);
}
}
Ce patron garantit que les erreurs brutes de PostgreSQL — vous savez, ces messages qui contiennent des noms de tables, des contraintes, des infos de schéma — ne remontent jamais jusqu'au client. Seuls des messages génériques et sécurisés sont exposés. C'est le genre de détail qui peut vous éviter une fuite d'information bien embarrassante.
FAQ
Quelle est la différence entre un Data Access Layer et un ORM ?
C'est une question qui revient tout le temps. Un ORM (comme Drizzle ou Prisma), c'est un outil qui vous permet d'interroger la base de données avec du TypeScript au lieu de SQL brut. Un Data Access Layer, c'est une couche architecturale qui utilise l'ORM en interne, mais qui ajoute par-dessus l'authentification, l'autorisation, la validation et la transformation en DTOs. En gros : l'ORM est un outil, le DAL est un patron de conception. Dans notre architecture, Drizzle est l'ORM utilisé à l'intérieur du DAL. Et la règle c'est : vous ne devriez jamais importer Drizzle directement dans un composant ou une action. Toujours passer par le DAL.
Faut-il utiliser un DAL pour un petit projet Next.js ?
Franchement, oui. Même pour un petit projet, un DAL léger vaut le coup. Pourquoi ? Parce que le modèle de sécurité de Next.js repose sur le fait que les Server Components et les Server Actions s'exécutent côté serveur, mais les objets retournés peuvent être sérialisés et envoyés au client. Sans DAL, c'est très facile d'exposer accidentellement des données sensibles. Pour un petit projet, votre DAL peut se limiter à un seul fichier lib/dal.ts avec quelques fonctions. L'investissement initial est minime, et ça vous évite des failles de sécurité qui coûtent cher à corriger après coup.
Comment tester les fonctions du Data Access Layer ?
Le DAL, c'est en fait l'endroit idéal pour les tests d'intégration. Vous pouvez tester chaque fonction en isolation en mockant la session et la base de données. Avec Vitest, ça donne quelque chose comme ça :
// __tests__/dal/users.test.ts
import { describe, it, expect, vi } from "vitest";
// Mocker la session
vi.mock("@/lib/dal/auth", () => ({
verifySession: vi.fn().mockResolvedValue({
userId: "user-123",
role: "user",
expiresAt: new Date(Date.now() + 3600000),
}),
}));
// Mocker la base de donnees
vi.mock("@/lib/db", () => ({
db: {
query: {
users: {
findFirst: vi.fn().mockResolvedValue({
id: "user-123",
name: "Jean Dupont",
email: "[email protected]",
passwordHash: "hash_secret",
role: "user",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
},
},
},
}));
describe("getUser", () => {
it("retourne un DTO sans le hash du mot de passe", async () => {
const { getUser } = await import("@/lib/dal/users");
const user = await getUser();
expect(user).toHaveProperty("name", "Jean Dupont");
expect(user).toHaveProperty("email", "[email protected]");
expect(user).not.toHaveProperty("passwordHash");
});
});
Si vous voulez des tests plus proches de la réalité, vous pouvez aussi utiliser une base de données de test avec des migrations automatiques via drizzle-kit push et des transactions rollbackées après chaque test. C'est un peu plus de setup, mais ça vaut le coup pour les parties critiques.
Le DAL remplace-t-il les Route Handlers dans Next.js ?
Non, pas du tout — ils ont des rôles différents. Les Route Handlers (app/api/.../route.ts) sont des endpoints HTTP destinés aux clients externes, aux webhooks, ou aux apps mobiles. Le DAL, c'est une couche interne destinée aux Server Components et aux Server Actions. Mais attention : en pratique, vos Route Handlers devraient eux aussi appeler le DAL au lieu d'accéder directement à la base de données. Comme ça :
// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { getPublishedPosts } from "@/lib/dal";
export async function GET() {
try {
const posts = await getPublishedPosts();
return NextResponse.json(posts);
} catch {
return NextResponse.json(
{ error: "Non autorise" },
{ status: 401 }
);
}
}
Comment gérer le cache dans le Data Access Layer ?
Alors, voyons voir... Next.js 16 offre plusieurs mécanismes de cache qu'on peut combiner avec le DAL. Pour les données qui changent rarement, utilisez unstable_cache (ou la directive use cache dans les versions expérimentales) autour de vos requêtes. Pour les données spécifiques à l'utilisateur, React.cache() déduplique les appels au sein d'un même rendu serveur. Voici un exemple avec unstable_cache pour des données publiques :
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
import { posts } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { toPostDTO } from "@/types/post";
export const getCachedPublishedPosts = unstable_cache(
async () => {
const publishedPosts = await db.query.posts.findMany({
where: eq(posts.published, true),
});
return publishedPosts.map(toPostDTO);
},
["published-posts"],
{ revalidate: 3600, tags: ["posts"] }
);
Un piège à éviter absolument : ne mettez jamais en cache des données spécifiques à un utilisateur avec unstable_cache sans inclure l'identifiant utilisateur dans la clé de cache. Sinon, un utilisateur pourrait voir les données d'un autre. Oui, ça paraît évident dit comme ça, mais j'ai vu l'erreur en production plus d'une fois. Pour les données personnelles, React.cache() est le bon choix parce que son scope est limité à une seule requête HTTP.
Au final, le Data Access Layer c'est bien plus qu'un simple patron d'organisation du code. C'est une couche de sécurité fondamentale qui protège vos données à chaque accès, centralise votre logique métier, et rend votre app Next.js prévisible et maintenable. Mon conseil : adoptez ce patron dès le début de votre projet, même sous une forme minimale. Commencez avec verifySession() et deux ou trois fonctions, puis étendez au fur et à mesure. Vous vous remercierez dans six mois quand l'équipe aura grandi et que la base de code aura triplé de volume.