"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."
The Problem That Happens in Every Project
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.
What Is a Data Access Layer?
The Data Access Layer (DAL) is an internal library that controls:
- How you fetch data
- When you fetch it
- What you pass to your components
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.
Why Does This Matter?
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.
The Difference Between Wrong and Right
The Wrong Way: Raw Data
// ❌ 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>
)
}
The Right Way: Safe Data
// ✅ 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.
Building Your DAL
Step 1: Define Data Transfer Objects (DTOs)
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;
}
Step 2: Write Access Functions
// 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,
}));
}
Step 3: Use It in Your Components
// 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>
)
}
Permission Checks: The Critical Part
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 },
});
}
Caching: Make Things Faster
The DAL is the perfect place for caching. In Next.js 16+, use the 'use cache' directive with cacheLife and cacheTag:
Pro Tip!
[!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,
};
}
Server Actions: Update Data Safely
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 };
}
}
Complete Example: A Blog System
Here's a complete example that ties everything together:
The DAL
// 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 } });
}
The Server Component
// 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>
)
}
The Server Action
// 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 };
}
}
The Form Component
// 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>
)
}
Best Practices
- One place for permissions - All permission checks in the DAL
- Always use DTOs - Never pass raw database data
- Handle errors - Throw clear errors; use
unstable_rethrowin actions - Cache strategically - Use the modern
'use cache'directive - Invalidate properly - Use
revalidateTagorupdateTagon updates - Use TypeScript - Define clear types for DTOs
The Bottom Line
A 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:
- More secure
- More clear
- Easier to maintain
- Easier to test
Start with a DAL from day one. Your future self will thank you.
Sources:
Happy Coding! — Ahmed Fahmy