••
"Stop passing raw database data to your components. The Data Access Layer is the answer. Here's why it matters and how to build it right."
Picture this: you've got a Server Component, you fetch data from the database, and you pass it straight to your component. Simple, right? Wrong.
// ❌ This is wrong
export default async function UserProfile({ userId }: { userId: string }) {
const user = await db.users.findUnique({
where: { id: userId }
})
// You're passing everything—the password, the tokens, everything
return <Profile user={user} />
}
See what's happening here? You're passing every field. The password hash. The API tokens. Sensitive data. Everything. If your component is a Client Component, all of it goes to the client. Then it goes into the JavaScript bundle. Then it goes to the browser. Then... you get the idea.
That's why the Data Access Layer exists. That's why it matters.
The Data Access Layer (DAL) is an internal library that controls:
It's not complicated. It's just one place where you put all your data-fetching logic.
// lib/dal.ts - Data Access Layer
import { db } from "@/lib/db";
// Check permissions
async function verifyUserAccess(userId: string, requestingUserId: string) {
if (userId !== requestingUserId) {
throw new Error("Unauthorized");
}
return true;
}
// Fetch data safely
export async function getUserProfile(userId: string, requestingUserId: string) {
// Check permissions first
await verifyUserAccess(userId, requestingUserId);
// Fetch the data
const user = await db.users.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error("User not found");
}
// Return only safe data
return {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
// Don't return: password, tokens, internalIds, etc.
};
}
That's it. One place. One logic. One security model.
Imagine you're working at a big company. You've got hundreds of components. Each one fetches data differently. Some check permissions, some don't. Some pass sensitive data, some don't. Chaos.
Then a security issue happens. You ask "where did this start?" and you have to dig through hundreds of files.
With a DAL? One place. Check permissions once. Return safe data once. Done.
// ❌ This is wrong
export default async function Dashboard() {
const users = await db.users.findMany()
return (
<div>
{users.map(user => (
<div key={user.id}>
<p>{user.name}</p>
<p>{user.email}</p>
<p>{user.passwordHash}</p> {/* 🚨 Exposing password! */}
<p>{user.apiToken}</p> {/* 🚨 Exposing token! */}
</div>
))}
</div>
)
}
// ✅ This is right
import { getAllUsers } from '@/lib/dal'
export default async function Dashboard() {
const users = await getAllUsers()
return (
<div>
{users.map(user => (
<div key={user.id}>
<p>{user.name}</p>
<p>{user.email}</p>
{/* No sensitive data */}
</div>
))}
</div>
)
}
The difference? One exposes everything. The other exposes only what's needed.
DTOs are the shapes of data you pass to your components. They contain only safe data.
// lib/dal/types.ts
export interface UserDTO {
id: string;
name: string;
email: string;
avatar: string | null;
}
export interface PostDTO {
id: string;
title: string;
content: string;
authorId: string;
createdAt: Date;
}
export interface CommentDTO {
id: string;
text: string;
authorId: string;
postId: string;
createdAt: Date;
}
// lib/dal/users.ts
import { db } from "@/lib/db";
import type { UserDTO } from "./types";
export async function getUserById(userId: string): Promise<UserDTO> {
const user = await db.users.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error("User not found");
}
return {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
};
}
export async function getUserByEmail(email: string): Promise<UserDTO | null> {
const user = await db.users.findUnique({
where: { email },
});
if (!user) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
};
}
export async function getAllUsers(): Promise<UserDTO[]> {
const users = await db.users.findMany();
return users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
}));
}
// app/users/page.tsx - Static list
import { getAllUsers } from '@/lib/dal/users'
export default async function UsersPage() {
const users = await getAllUsers()
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>
<p>{user.name}</p>
<p>{user.email}</p>
</li>
))}
</ul>
</div>
)
}
// app/users/[id]/page.tsx - Dynamic detail (Next.js 15+)
import { getUserById } from '@/lib/dal/users'
export default async function UserPage({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const user = await getUserById(id)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
The DAL is the only place where you check permissions. This is crucial.
// lib/dal/posts.ts
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import type { PostDTO } from "./types";
export async function getPostById(postId: string): Promise<PostDTO> {
const post = await db.posts.findUnique({
where: { id: postId },
});
if (!post) {
throw new Error("Post not found");
}
return {
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
};
}
export async function getUserPosts(userId: string): Promise<PostDTO[]> {
// Check permissions
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error("Unauthorized");
}
// If requesting another user's posts, verify permissions
if (userId !== currentUser.id && !currentUser.isAdmin) {
throw new Error("Forbidden");
}
const posts = await db.posts.findMany({
where: { authorId: userId },
});
return posts.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
}));
}
export async function deletePost(postId: string): Promise<void> {
// Check permissions
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error("Unauthorized");
}
const post = await db.posts.findUnique({
where: { id: postId },
});
if (!post) {
throw new Error("Post not found");
}
// Only admin or post author can delete
if (post.authorId !== currentUser.id && !currentUser.isAdmin) {
throw new Error("Forbidden");
}
await db.posts.delete({
where: { id: postId },
});
}
The DAL is the perfect place for caching. In Next.js 16+, use the 'use cache' directive with cacheLife and cacheTag:
[!NOTE]
To use 'use cache', you must enable cacheComponents: true in your next.config.ts.
// lib/dal/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function getCachedPosts() {
'use cache';
cacheLife('hours');
cacheTag('posts');
return await db.posts.findMany({
select: {
id: true,
title: true,
content: true,
authorId: true,
createdAt: true,
},
});
}
export async function getCachedPostById(postId: string) {
'use cache';
cacheLife('hours');
cacheTag(`post-${postId}`);
const post = await db.posts.findUnique({
where: { id: postId },
});
if (!post) {
throw new Error("Post not found");
}
return {
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
};
}
Use Server Actions with your DAL to update data:
// app/actions.ts
"use server";
import { revalidateTag, unstable_rethrow } from "next/cache";
import { createPost, updatePost, deletePost } from "@/lib/dal/posts";
export async function createPostAction(formData: FormData) {
try {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || !content) {
throw new Error("Title and content are required");
}
const post = await createPost(title, content);
// Invalidate cache
revalidateTag("posts");
return { success: true, data: post };
} catch (error) {
unstable_rethrow(error);
return { success: false, error: error.message };
}
}
export async function updatePostAction(postId: string, formData: FormData) {
try {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await updatePost(postId, title, content);
// Invalidate cache
revalidateTag("posts");
revalidateTag(`post-${postId}`);
return { success: true, data: post };
} catch (error) {
unstable_rethrow(error);
return { success: false, error: error.message };
}
}
export async function deletePostAction(postId: string) {
try {
await deletePost(postId);
// Invalidate cache
revalidateTag("posts");
revalidateTag(`post-${postId}`);
return { success: true };
} catch (error) {
unstable_rethrow(error);
return { success: false, error: error.message };
}
}
Here's a complete example that ties everything together:
// lib/dal/index.ts
import { db } from "@/lib/db";
import { cacheLife, cacheTag } from "next/cache";
import { getCurrentUser } from "@/lib/auth";
// Types
export interface PostDTO {
id: string;
title: string;
content: string;
authorId: string;
createdAt: Date;
}
// Cached queries
export async function getCachedPosts() {
'use cache';
cacheLife('hours');
cacheTag('posts');
const posts = await db.posts.findMany({
orderBy: { createdAt: "desc" },
});
return posts.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
}));
}
// Mutations
export async function createPost(
title: string,
content: string,
): Promise<PostDTO> {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const post = await db.posts.create({
data: {
title,
content,
authorId: user.id,
},
});
return {
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
};
}
export async function updatePost(
postId: string,
title: string,
content: string,
): Promise<PostDTO> {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const post = await db.posts.findUnique({ where: { id: postId } });
if (!post) throw new Error("Post not found");
if (post.authorId !== user.id && !user.isAdmin) {
throw new Error("Forbidden");
}
const updated = await db.posts.update({
where: { id: postId },
data: { title, content },
});
return {
id: updated.id,
title: updated.title,
content: updated.content,
authorId: updated.authorId,
createdAt: updated.createdAt,
};
}
export async function deletePost(postId: string): Promise<void> {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const post = await db.posts.findUnique({ where: { id: postId } });
if (!post) throw new Error("Post not found");
if (post.authorId !== user.id && !user.isAdmin) {
throw new Error("Forbidden");
}
await db.posts.delete({ where: { id: postId } });
}
// app/blog/page.tsx
import { getCachedPosts } from '@/lib/dal'
import PostCard from '@/app/components/post-card'
import CreatePostForm from '@/app/components/create-post-form'
export default async function BlogPage() {
const posts = await getCachedPosts()
return (
<div>
<h1>Blog</h1>
<CreatePostForm />
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
)
}
// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { createPost } from "@/lib/dal";
export async function createPostAction(formData: FormData) {
try {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await createPost(title, content);
revalidateTag("posts");
return { success: true, data: post };
} catch (error) {
return { success: false, error: error.message };
}
}
// app/components/create-post-form.tsx
'use client'
import { createPostAction } from '@/app/actions'
import { useState } from 'react'
export default function CreatePostForm() {
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const result = await createPostAction(formData)
if (result.success) {
e.currentTarget.reset()
} else {
alert(`Error: ${result.error}`)
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="content"
placeholder="Post content"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
unstable_rethrow in actions'use cache' directiverevalidateTag or updateTag on updatesA Data Access Layer isn't complicated. It's just one place where you put all your data-fetching logic and permission checks. It makes you:
Start with a DAL from day one. Your future self will thank you.
Sources:
Happy Coding! — Ahmed Fahmy